From 0b5717da6d9fc0a5d7c6f6dd17f9eca0c0d07fe1 Mon Sep 17 00:00:00 2001 From: Ze'ev Klapow Date: Wed, 10 Jun 2026 10:50:36 -0400 Subject: [PATCH 1/4] Add boomslang-py Python host package --- .github/workflows/build.yml | 70 ++++- CLAUDE.md | 19 ++ README.md | 18 ++ boomslang-py/.gitignore | 5 + boomslang-py/README.md | 107 +++++++ boomslang-py/pyproject.toml | 28 ++ boomslang-py/src/boomslang/__init__.py | 36 +++ boomslang-py/src/boomslang/_assets.py | 33 ++ boomslang-py/src/boomslang/_engine.py | 68 +++++ boomslang-py/src/boomslang/_trampolines.py | 64 ++++ boomslang-py/src/boomslang/_version.py | 1 + boomslang-py/src/boomslang/errors.py | 27 ++ boomslang-py/src/boomslang/limits.py | 24 ++ boomslang-py/src/boomslang/result.py | 13 + boomslang-py/src/boomslang/sandbox.py | 335 +++++++++++++++++++++ boomslang-py/tests/conftest.py | 18 ++ boomslang-py/tests/test_execute.py | 64 ++++ boomslang-py/tests/test_host_functions.py | 81 +++++ boomslang-py/tests/test_limits.py | 54 ++++ boomslang-py/tests/test_mounts.py | 52 ++++ boomslang-py/tests/test_packages.py | 43 +++ justfile | 26 ++ scripts/build-python-wheel.sh | 49 +++ scripts/stage-python-runtime.sh | 76 +++++ 24 files changed, 1310 insertions(+), 1 deletion(-) create mode 100644 boomslang-py/.gitignore create mode 100644 boomslang-py/README.md create mode 100644 boomslang-py/pyproject.toml create mode 100644 boomslang-py/src/boomslang/__init__.py create mode 100644 boomslang-py/src/boomslang/_assets.py create mode 100644 boomslang-py/src/boomslang/_engine.py create mode 100644 boomslang-py/src/boomslang/_trampolines.py create mode 100644 boomslang-py/src/boomslang/_version.py create mode 100644 boomslang-py/src/boomslang/errors.py create mode 100644 boomslang-py/src/boomslang/limits.py create mode 100644 boomslang-py/src/boomslang/result.py create mode 100644 boomslang-py/src/boomslang/sandbox.py create mode 100644 boomslang-py/tests/conftest.py create mode 100644 boomslang-py/tests/test_execute.py create mode 100644 boomslang-py/tests/test_host_functions.py create mode 100644 boomslang-py/tests/test_limits.py create mode 100644 boomslang-py/tests/test_mounts.py create mode 100644 boomslang-py/tests/test_packages.py create mode 100755 scripts/build-python-wheel.sh create mode 100755 scripts/stage-python-runtime.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ff3a56..51b4f19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -129,9 +129,71 @@ jobs: df -h docker system df || true + python-wheel: + name: Python wheel + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download runtime artifacts + uses: actions/download-artifact@v8 + with: + name: boomslang-runtime-${{ github.sha }} + path: dist + + - name: Install runtime resources + run: | + set -euo pipefail + mkdir -p stage + tar -xzf "dist/boomslang-runtime-${GITHUB_SHA}.tar.gz" -C stage + mkdir -p core/src/main/resources/python + cp -R stage/python/bin stage/python/usr core/src/main/resources/python/ + + - name: Stage runtime into Python package + run: ./scripts/stage-python-runtime.sh + + - name: Resolve wheel version + run: | + if [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" == v* ]]; then + echo "BOOMSLANG_WHEEL_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + else + echo "BOOMSLANG_WHEEL_VERSION=0.0.0+g${GITHUB_SHA::12}" >> "$GITHUB_ENV" + fi + + - name: Build wheel + run: | + pip install build + ./scripts/build-python-wheel.sh + + - name: Test installed wheel + run: | + python -m venv /tmp/wheel-venv + /tmp/wheel-venv/bin/pip install boomslang-py/dist/*.whl pytest + cd /tmp + /tmp/wheel-venv/bin/pytest "$GITHUB_WORKSPACE/boomslang-py/tests" + + - name: Upload wheel artifact + uses: actions/upload-artifact@v7 + with: + name: boomslang-wheel-${{ github.sha }} + path: boomslang-py/dist/*.whl + if-no-files-found: error + retention-days: 90 + release: name: Publish runtime release - needs: build + needs: [build, python-wheel] if: | github.event_name == 'push' && ( @@ -151,6 +213,12 @@ jobs: name: boomslang-runtime-${{ github.sha }} path: dist + - name: Download Python wheel + uses: actions/download-artifact@v8 + with: + name: boomslang-wheel-${{ github.sha }} + path: dist + - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/CLAUDE.md b/CLAUDE.md index c654b6e..a8ca3c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,9 +78,28 @@ mvn compile -pl core mvn test -pl tests ``` +### Python package (boomslang-py) + +`boomslang-py/` is a Python host: a wheel bundling the WASM runtime, executed +with wasmtime-py. Published as a GitHub release asset by CI (not PyPI). + +```bash +just python-stage # copy runtime resources + overlay into the package (needs fetch-main-wasm or resources first) +just python-test # staged resources + venv + pytest +just python-wheel # build dist/boomslang--py3-none-any.whl +``` + +Key constraint: the guest libc's preopen table is baked in at Wizer time and +binds host preopens **positionally** — fd 3 = `/usr` (runtime, read-only), +fd 4 = `/lib`, fd 5 = `/work`, fd 6 = `/tmp`. The guest-path strings passed +to the WASI config are ignored by the guest, and arbitrary extra mount points +are unreachable. Any host (Java, Rust, or Python) must register mounts in +this order. + ## Project Structure - `core/` — Java runtime (PythonExecutorFactory, PythonInstance, CopyOnWriteMemory) +- `boomslang-py/` — Python host package (Sandbox API, wheel bundling the WASM runtime) - `python-host/` — Rust WASM host (PyO3 wrapper around CPython) - `cpython/` — All native WASM build infrastructure: - `cpython-wasi/` — CPython → WASM build pipeline diff --git a/README.md b/README.md index 478093a..69635da 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,26 @@ The extension ABI is not tied to Java. An extension crate declares its contract | Host language | Status | Runtime | Host adapter support | | --- | --- | --- | --- | | Java | Primary host | Chicory | Stock runtime API, `HostBridge`, generated Java adapters with `--java-out` or `emit_java_host(...)` | +| Python | Supported host package | Wasmtime (wasmtime-py) | `boomslang-py/` wheel bundling the runtime; `Sandbox` API with host functions; see `boomslang-py/README.md` | | Rust | Supported example host | Wasmtime | Generated Rust adapters with `--rust-host-out` or `emit_rust_host(...)`; see `examples/rust-host/` | | Other languages | ABI target only | Any WASM runtime with compatible imports | Use the ABI JSON to implement the same pointer/length lowering and return-buffer protocol | The Maven artifact is still Java-first and includes the bundled runtime. Rust hosting is there for embedders that want to run the same Boomslang WASM from a Rust process. +## Python host usage + +The `boomslang-py/` package lets regular Python programs run sandboxed Python: it bundles the same WASM runtime and executes it with wasmtime. Wheels are attached to GitHub releases (not PyPI). + +```python +from boomslang import Sandbox + +with Sandbox() as sandbox: + result = sandbox.execute("print('hello from the sandbox')") + print(result.stdout) +``` + +See `boomslang-py/README.md` for resource limits, host functions, and the guest filesystem layout. Local build: `just fetch-main-wasm && just python-test`; wheel: `just python-wheel`. + ## Java host usage Use the default artifact for the bundled Python runtime: @@ -274,6 +289,8 @@ Common local loops: just fetch-main-wasm # download latest main runtime resources from GitHub release assets just build # package with AOT, skips tests just test # tests module +just python-test # Python package test suite (stages runtime resources first) +just python-wheel # build the Python wheel mvn compile -pl core mvn test -pl tests ``` @@ -325,6 +342,7 @@ just test ## Repo map - `core/`: Java runtime API and bundled Python resources +- `boomslang-py/`: Python host package (wheel bundling the WASM runtime) - `tests/`: integration tests - `benchmarks/`: JMH benchmarks - `python-host/`: stock Rust WASM host diff --git a/boomslang-py/.gitignore b/boomslang-py/.gitignore new file mode 100644 index 0000000..021e40f --- /dev/null +++ b/boomslang-py/.gitignore @@ -0,0 +1,5 @@ +src/boomslang/_runtime/ +dist/ +.venv/ +*.egg-info/ +__pycache__/ diff --git a/boomslang-py/README.md b/boomslang-py/README.md new file mode 100644 index 0000000..ea1c9c8 --- /dev/null +++ b/boomslang-py/README.md @@ -0,0 +1,107 @@ +# boomslang (Python) + +Run sandboxed Python code from Python. This package bundles boomslang's +CPython 3.14 runtime compiled to WebAssembly (with numpy, pandas, pydantic, +matplotlib, Pillow, and ijson preloaded) and executes it with +[wasmtime](https://pypi.org/project/wasmtime/). Guest code has no network +access and can only touch the directories you mount. + +## Install + +Wheels are published as GitHub release assets (not PyPI): + +```bash +pip install https://github.com/HubSpot/boomslang/releases/download//boomslang--py3-none-any.whl +``` + +From a source checkout: `just fetch-main-wasm && just python-stage`, then +`pip install -e boomslang-py`. + +## Quickstart + +```python +from boomslang import Sandbox + +with Sandbox() as sandbox: + result = sandbox.execute("print('hello from the sandbox')") + print(result.stdout) # hello from the sandbox + print(result.exit_code) # 0 +``` + +Interpreter state persists across `execute()` calls on the same sandbox; +`sandbox.reset()` restores the pristine interpreter image (files under the +work dir persist). Python errors in guest code don't raise on the host — they +surface as `exit_code != 0` with the traceback in `result.stderr`. + +## Resource limits + +```python +from boomslang import ResourceLimits, Sandbox + +sandbox = Sandbox(limits=ResourceLimits( + timeout=10.0, # seconds, default 120 + max_memory_bytes=512 * 1024 * 1024, # default: wasm32 4 GiB cap + max_output_bytes=1024 * 1024, # per stream, default 10 MiB +)) +``` + +A timeout raises `PythonTimeoutError` and poisons the sandbox; call +`reset()` to revive it. `max_memory_bytes` must exceed the baseline runtime +image (~150 MB) or instantiation fails. + +## Filesystem + +The guest filesystem layout is fixed by the runtime image (the guest libc's +preopen table is baked in at build time): + +| Guest path | Host side | Access | +|------------|------------------------------------|------------| +| `/usr` | bundled runtime + stdlib | read-only | +| `/lib` | `lib_dir=` (on the guest sys.path) | read-write | +| `/work` | `work_dir=` | read-write | +| `/tmp` | managed per-sandbox temp dir | read-write | + +`work_dir` and `lib_dir` default to managed temporary directories +(`sandbox.work_dir` / `sandbox.lib_dir` expose the host paths). Arbitrary +additional mount points are not supported — share files through `/work`, and +make extra pure-Python libraries importable by placing them in `lib_dir`. + +There is no stdin: `input()` raises `EOFError`. + +## Host functions + +Guest code can call back into your process through the bundled +`boomslang_host` bridge. Arguments and results cross the boundary as JSON; +results are capped at 1 MiB by the guest-side buffer. + +```python +sandbox = Sandbox() + +@sandbox.host_function("lookup_user") +def lookup_user(args): + return {"id": args["id"], "name": "Ada"} + +result = sandbox.execute(""" +import json +from boomslang_host import call +user = json.loads(call("lookup_user", json.dumps({"id": 7}))) +print(user["name"]) +""") +``` + +For full control pass `call_handler=lambda name, args_json: ...` (raw JSON +strings in and out), and `on_log=lambda level, message: ...` to receive +`boomslang_host.log()` output (default: forwarded to the `boomslang.guest` +logger). + +## Performance notes + +- The first `Sandbox()` ever created on a machine compiles the ~100 MB WASM + module (seconds to a couple of minutes depending on hardware). The compiled + module is cached on disk by wasmtime, so subsequent processes start in + under a second. +- Each sandbox materializes its own copy of the runtime's linear memory + (hundreds of MB). Reuse sandboxes (with `reset()`) where isolation + requirements allow. +- `pip install --no-compile` skips byte-compiling the bundled stdlib tree, + which the guest never reads anyway. diff --git a/boomslang-py/pyproject.toml b/boomslang-py/pyproject.toml new file mode 100644 index 0000000..89787d6 --- /dev/null +++ b/boomslang-py/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "boomslang" +dynamic = ["version"] +description = "Sandboxed CPython 3.14 execution via WebAssembly (wasmtime)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["wasmtime>=36"] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[tool.hatch.version] +path = "src/boomslang/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/boomslang"] +# The staged runtime assets are gitignored; force their inclusion in the wheel. +artifacts = ["src/boomslang/_runtime/**"] + +[tool.hatch.build.targets.sdist] +exclude = ["src/boomslang/_runtime"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/boomslang-py/src/boomslang/__init__.py b/boomslang-py/src/boomslang/__init__.py new file mode 100644 index 0000000..54224c6 --- /dev/null +++ b/boomslang-py/src/boomslang/__init__.py @@ -0,0 +1,36 @@ +"""Sandboxed CPython 3.14 execution via WebAssembly. + +Quickstart: + + from boomslang import Sandbox + + with Sandbox() as sandbox: + result = sandbox.execute("print('hello from the sandbox')") + print(result.stdout) +""" + +from ._version import __version__ +from .errors import ( + BoomslangError, + PythonExecutionError, + PythonTimeoutError, + RuntimeAssetsError, + SandboxClosedError, + SandboxPoisonedError, +) +from .limits import ResourceLimits +from .result import ExecutionResult +from .sandbox import Sandbox + +__all__ = [ + "BoomslangError", + "ExecutionResult", + "PythonExecutionError", + "PythonTimeoutError", + "ResourceLimits", + "RuntimeAssetsError", + "Sandbox", + "SandboxClosedError", + "SandboxPoisonedError", + "__version__", +] diff --git a/boomslang-py/src/boomslang/_assets.py b/boomslang-py/src/boomslang/_assets.py new file mode 100644 index 0000000..61511d5 --- /dev/null +++ b/boomslang-py/src/boomslang/_assets.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from .errors import RuntimeAssetsError + +_RUNTIME_DIR = Path(__file__).resolve().parent / "_runtime" + + +def runtime_dir() -> Path: + if not _RUNTIME_DIR.is_dir(): + raise RuntimeAssetsError( + f"Runtime assets not found at {_RUNTIME_DIR}. " + "If running from a source checkout, stage them first with " + "'just python-stage' (after 'just fetch-main-wasm')." + ) + return _RUNTIME_DIR + + +def wasm_path() -> Path: + path = runtime_dir() / "bin" / "boomslang.wasm" + if not path.is_file(): + raise RuntimeAssetsError(f"WASM binary not found at {path}") + with path.open("rb") as f: + if f.read(4) != b"\0asm": + raise RuntimeAssetsError(f"{path} is not a WASM binary") + return path + + +def usr_host_dir() -> Path: + """Host directory bound to /usr in the guest (contains local/lib/python3.14).""" + path = runtime_dir() / "usr" + if not (path / "local" / "lib" / "python3.14").is_dir(): + raise RuntimeAssetsError(f"Python stdlib tree not found under {path}") + return path diff --git a/boomslang-py/src/boomslang/_engine.py b/boomslang-py/src/boomslang/_engine.py new file mode 100644 index 0000000..86f9d51 --- /dev/null +++ b/boomslang-py/src/boomslang/_engine.py @@ -0,0 +1,68 @@ +import threading + +import wasmtime + +from ._assets import wasm_path + +EPOCH_TICK_SECONDS = 0.01 + +# Effectively "no deadline" for stores that are not currently executing. +DISARMED_DEADLINE_TICKS = 2**62 + +# Wasmtime caps max_wasm_stack at the (unexposed) 2 MiB async_stack_size. +# CPython's recursion mostly lives on the guest's 4 MiB linear-memory shadow +# stack, so 1.5 MiB of native wasm stack is comfortable headroom over the +# 512 KiB default. +_MAX_WASM_STACK = 1536 * 1024 + + +class _Runtime: + """Process-wide wasmtime Engine + compiled Module + epoch ticker thread. + + Compiling the ~100 MB module is expensive (minutes on a cold wasmtime + cache), so it is shared across all sandboxes. Epoch interruption is + engine-global: a single ticker thread increments the epoch on a fixed + cadence and every Store arms its own deadline in ticks, so one ticker + serves all sandboxes without cross-cancellation. + """ + + def __init__(self) -> None: + config = wasmtime.Config() + config.epoch_interruption = True + config.cache = True + config.max_wasm_stack = _MAX_WASM_STACK + self.engine = wasmtime.Engine(config) + self.module = wasmtime.Module.from_file(self.engine, str(wasm_path())) + self._ticker_lock = threading.Lock() + self._ticker_started = False + + def ensure_ticker(self) -> None: + with self._ticker_lock: + if self._ticker_started: + return + thread = threading.Thread( + target=self._tick_forever, name="boomslang-epoch-ticker", daemon=True + ) + thread.start() + self._ticker_started = True + + def _tick_forever(self) -> None: + ticker = threading.Event() + while True: + ticker.wait(EPOCH_TICK_SECONDS) + self.engine.increment_epoch() + + def deadline_ticks(self, timeout_seconds: float) -> int: + return max(1, int(timeout_seconds / EPOCH_TICK_SECONDS) + 1) + + +_runtime_lock = threading.Lock() +_runtime_instance: _Runtime | None = None + + +def runtime() -> _Runtime: + global _runtime_instance + with _runtime_lock: + if _runtime_instance is None: + _runtime_instance = _Runtime() + return _runtime_instance diff --git a/boomslang-py/src/boomslang/_trampolines.py b/boomslang-py/src/boomslang/_trampolines.py new file mode 100644 index 0000000..8b00c7b --- /dev/null +++ b/boomslang-py/src/boomslang/_trampolines.py @@ -0,0 +1,64 @@ +"""Host-function imports required by the WASM module. + +The runtime imports exactly two functions from the "boomslang" module (see +extensions/host-bridge and examples/rust-host/abi/boomslang_host.abi.json): + + call(name_ptr, name_len, args_ptr, args_len, result_ptr, result_max_len) -> i32 + Returns bytes written into result_ptr, or -1 (handler error) / + -2 (result larger than result_max_len). The guest-side bridge uses a + fixed 1 MiB result buffer and does not retry on -2. + + log(level, msg_ptr, msg_len) + +Exceptions must never escape a trampoline — that would trap the guest. Errors +are reported through the negative return codes, matching the Java host. +""" + +import logging + +from wasmtime import FuncType, Linker, ValType + +logger = logging.getLogger(__name__) + +_I32 = ValType.i32() +_CALL_TYPE = FuncType([_I32] * 6, [_I32]) +_LOG_TYPE = FuncType([_I32] * 3, []) + +CALL_ERROR = -1 +CALL_RESULT_TOO_LARGE = -2 + + +def define_boomslang_imports(linker: Linker, sandbox) -> None: + def call(caller, name_ptr, name_len, args_ptr, args_len, result_ptr, result_max_len): + try: + memory = caller.get("memory") + name = bytes(memory.read(caller, name_ptr, name_ptr + name_len)).decode("utf-8") + args = bytes(memory.read(caller, args_ptr, args_ptr + args_len)).decode("utf-8") + result = sandbox._dispatch_host_call(name, args) + data = result.encode("utf-8") + if len(data) > result_max_len: + logger.error( + "host function %r result is %d bytes, exceeding the guest's %d-byte buffer", + name, + len(data), + result_max_len, + ) + return CALL_RESULT_TOO_LARGE + memory.write(caller, data, result_ptr) + return len(data) + except Exception: + logger.exception("host function call failed") + return CALL_ERROR + + def log(caller, level, msg_ptr, msg_len): + try: + memory = caller.get("memory") + message = bytes(memory.read(caller, msg_ptr, msg_ptr + msg_len)).decode( + "utf-8", errors="replace" + ) + sandbox._on_log(level, message) + except Exception: + logger.exception("guest log handler failed") + + linker.define_func("boomslang", "call", _CALL_TYPE, call, access_caller=True) + linker.define_func("boomslang", "log", _LOG_TYPE, log, access_caller=True) diff --git a/boomslang-py/src/boomslang/_version.py b/boomslang-py/src/boomslang/_version.py new file mode 100644 index 0000000..a21d37c --- /dev/null +++ b/boomslang-py/src/boomslang/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.0.dev0" \ No newline at end of file diff --git a/boomslang-py/src/boomslang/errors.py b/boomslang-py/src/boomslang/errors.py new file mode 100644 index 0000000..80ca997 --- /dev/null +++ b/boomslang-py/src/boomslang/errors.py @@ -0,0 +1,27 @@ +class BoomslangError(Exception): + """Base class for all boomslang errors.""" + + +class RuntimeAssetsError(BoomslangError): + """The bundled WASM runtime assets are missing or invalid.""" + + +class PythonExecutionError(BoomslangError): + """Guest execution failed at the runtime level (trap, output limit, bad input). + + Note: ordinary Python exceptions inside the sandbox do NOT raise this — they + are reported via ExecutionResult.exit_code and the traceback on stderr. + """ + + +class PythonTimeoutError(BoomslangError): + """Guest execution exceeded ResourceLimits.timeout. The sandbox is poisoned + until reset() is called.""" + + +class SandboxClosedError(BoomslangError): + """The sandbox has been closed.""" + + +class SandboxPoisonedError(BoomslangError): + """The sandbox was poisoned by a timeout or trap — call reset() before reuse.""" diff --git a/boomslang-py/src/boomslang/limits.py b/boomslang-py/src/boomslang/limits.py new file mode 100644 index 0000000..299c2f7 --- /dev/null +++ b/boomslang-py/src/boomslang/limits.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ResourceLimits: + """Execution limits for a Sandbox. Defaults mirror the Java ResourceLimits.""" + + timeout: float = 120.0 + """Wall-clock seconds a single execute() may run before the sandbox is + interrupted and poisoned.""" + + max_memory_bytes: int | None = None + """Cap on the sandbox's linear memory. None means the wasm32 4 GiB cap.""" + + max_output_bytes: int = 10 * 1024 * 1024 + """Maximum bytes accepted from each of the guest's stdout/stderr buffers.""" + + def __post_init__(self) -> None: + if self.timeout <= 0: + raise ValueError("timeout must be positive") + if self.max_memory_bytes is not None and self.max_memory_bytes <= 0: + raise ValueError("max_memory_bytes must be positive") + if self.max_output_bytes <= 0: + raise ValueError("max_output_bytes must be positive") diff --git a/boomslang-py/src/boomslang/result.py b/boomslang-py/src/boomslang/result.py new file mode 100644 index 0000000..c0961c4 --- /dev/null +++ b/boomslang-py/src/boomslang/result.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ExecutionResult: + stdout: str + stderr: str + exit_code: int + duration_ms: float + + @property + def ok(self) -> bool: + return self.exit_code == 0 diff --git a/boomslang-py/src/boomslang/sandbox.py b/boomslang-py/src/boomslang/sandbox.py new file mode 100644 index 0000000..a7c1005 --- /dev/null +++ b/boomslang-py/src/boomslang/sandbox.py @@ -0,0 +1,335 @@ +import json +import logging +import os +import tempfile +import threading +import time +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any + +import wasmtime +from wasmtime import DirPerms, FilePerms, Linker, Store, WasiConfig + +from ._assets import usr_host_dir +from ._engine import DISARMED_DEADLINE_TICKS, runtime +from ._trampolines import define_boomslang_imports +from .errors import ( + PythonExecutionError, + PythonTimeoutError, + SandboxClosedError, + SandboxPoisonedError, +) +from .limits import ResourceLimits +from .result import ExecutionResult + +logger = logging.getLogger(__name__) +guest_logger = logging.getLogger("boomslang.guest") + +HostFunction = Callable[[Any], Any] +"""Receives the JSON-decoded args; its return value is JSON-encoded back to the guest.""" + +GUEST_WORK_PATH = "/work" + +_EXPORT_NAMES = ( + "alloc", + "dealloc", + "execute", + "reset_state", + "get_stdout_len", + "get_stderr_len", + "get_stdout", + "get_stderr", + "get_heap_pages", +) + +_LOG_LEVELS = {0: logging.DEBUG, 1: logging.DEBUG, 2: logging.INFO, 3: logging.WARNING} + + +def _default_on_log(level: int, message: str) -> None: + guest_logger.log(_LOG_LEVELS.get(level, logging.ERROR), "%s", message) + + +class Sandbox: + """A sandboxed CPython 3.14 interpreter running in WebAssembly. + + Each Sandbox is an isolated interpreter instantiated from the bundled + Wizer-pre-initialized runtime image. Interpreter state persists across + execute() calls; reset() restores the pristine image (files under the + work dir persist). + + Guest code can call back into the host via the bundled bridge module: + + from boomslang_host import call, log + call("my_function", '{"key": "value"}') # JSON in, JSON out + + Host-function results are capped at 1 MiB by the guest-side bridge buffer. + + The guest filesystem layout is fixed by the runtime image (the libc + preopen table is baked in at Wizer time, bound positionally to the host's + preopen order): /usr (bundled runtime, read-only), /lib (extra Python + libraries, on sys.path), /work (shared work dir), /tmp (ephemeral). + Arbitrary additional mount points are not supported — share files through + work_dir or lib_dir. + + Not thread-safe for concurrent calls on the same instance (calls are + serialized internally); use one Sandbox per thread for parallelism. + """ + + def __init__( + self, + *, + limits: ResourceLimits | None = None, + work_dir: str | os.PathLike | None = None, + lib_dir: str | os.PathLike | None = None, + host_functions: Mapping[str, HostFunction] | None = None, + call_handler: Callable[[str, str], str] | None = None, + on_log: Callable[[int, str], None] | None = None, + python_path: Sequence[str] = (GUEST_WORK_PATH,), + ) -> None: + self._limits = limits or ResourceLimits() + self._host_functions: dict[str, HostFunction] = dict(host_functions or {}) + self._call_handler = call_handler + self._on_log = on_log or _default_on_log + self._python_path = tuple(python_path) + self._lock = threading.RLock() + self._closed = False + self._poisoned = False + + self._scratch = tempfile.TemporaryDirectory(prefix="boomslang-scratch-") + (Path(self._scratch.name) / "tmp").mkdir() + (Path(self._scratch.name) / "lib").mkdir() + if work_dir is None: + self._managed_work = tempfile.TemporaryDirectory(prefix="boomslang-work-") + self._work_dir = Path(self._managed_work.name) + else: + self._managed_work = None + self._work_dir = Path(work_dir) + self._work_dir.mkdir(parents=True, exist_ok=True) + if lib_dir is None: + self._lib_dir = Path(self._scratch.name) / "lib" + else: + self._lib_dir = Path(lib_dir) + if not self._lib_dir.is_dir(): + raise ValueError(f"lib_dir is not a directory: {self._lib_dir}") + + self._instantiate() + + # ------------------------------------------------------------------ + # Lifecycle + + def _instantiate(self) -> None: + rt = runtime() + rt.ensure_ticker() + + store = Store(rt.engine) + store.set_epoch_deadline(DISARMED_DEADLINE_TICKS) + if self._limits.max_memory_bytes is not None: + store.set_limits(memory_size=self._limits.max_memory_bytes) + + wasi = WasiConfig() + wasi.env = [("PYTHONHOME", "/usr/local"), ("PYTHONDONTWRITEBYTECODE", "1")] + # The guest libc's preopen table was baked in by Wizer and binds + # positionally: fd 3 = /usr, fd 4 = /lib, fd 5 = /work, fd 6 = /tmp. + # Registration order here is the contract; the guest_path strings are + # ignored by the guest. + wasi.preopen_dir(str(usr_host_dir()), "/usr", DirPerms.READ_ONLY, FilePerms.READ_ONLY) + wasi.preopen_dir(str(self._lib_dir), "/lib") + wasi.preopen_dir(str(self._work_dir), GUEST_WORK_PATH) + wasi.preopen_dir(str(Path(self._scratch.name) / "tmp"), "/tmp") + # Guest stdout/stderr are captured by in-guest buffers; WASI streams + # only carry low-level runtime diagnostics. + wasi.stdout_file = str(Path(self._scratch.name) / ".wasi-stdout.log") + wasi.stderr_file = str(Path(self._scratch.name) / ".wasi-stderr.log") + store.set_wasi(wasi) + + linker = Linker(rt.engine) + linker.define_wasi() + define_boomslang_imports(linker, self) + + instance = linker.instantiate(store, rt.module) + exports = instance.exports(store) + self._store = store + self._memory = exports["memory"] + self._fn = {name: exports[name] for name in _EXPORT_NAMES} + + self._bootstrap_python_path() + + def _bootstrap_python_path(self) -> None: + if not self._python_path: + return + script = "import sys" + for entry in self._python_path: + script += f"\nsys.path.insert(0, {entry!r})" + status = self._call_execute(script) + if status != 0: + logger.warning( + "python_path injection failed with code %s: %s", + status, + self._read_stream("stderr"), + ) + + def reset(self) -> None: + """Restore the pristine interpreter image. Files in the work dir persist.""" + with self._lock: + if self._closed: + raise SandboxClosedError("Sandbox has been closed") + self._instantiate() + self._poisoned = False + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + self._fn = {} + self._memory = None + self._store = None + self._scratch.cleanup() + if self._managed_work is not None: + self._managed_work.cleanup() + + def __enter__(self) -> "Sandbox": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # ------------------------------------------------------------------ + # Execution + + def execute(self, code: str) -> ExecutionResult: + with self._lock: + self._check_usable() + start = time.perf_counter() + exit_code = self._call_execute(code) + stdout = self._read_stream("stdout") + stderr = self._read_stream("stderr") + duration_ms = (time.perf_counter() - start) * 1000 + return ExecutionResult( + stdout=stdout, stderr=stderr, exit_code=exit_code, duration_ms=duration_ms + ) + + def _call_execute(self, code: str) -> int: + data = code.encode("utf-8") + ptr = self._alloc(len(data)) + try: + self._memory.write(self._store, data, ptr) + self._store.set_epoch_deadline(runtime().deadline_ticks(self._limits.timeout)) + try: + return int(self._fn["execute"](self._store, ptr, len(data))) + except wasmtime.Trap as trap: + self._poisoned = True + self._disarm_deadline() + if trap.trap_code == wasmtime.TrapCode.INTERRUPT: + raise PythonTimeoutError( + f"execution exceeded the {self._limits.timeout}s timeout; " + "the sandbox is poisoned until reset()" + ) from trap + stderr = self._try_read_stderr() + message = stderr or trap.message + raise PythonExecutionError(message) from trap + finally: + self._disarm_deadline() + finally: + self._dealloc(ptr, len(data)) + + def _disarm_deadline(self) -> None: + self._store.set_epoch_deadline(DISARMED_DEADLINE_TICKS) + + def _alloc(self, size: int) -> int: + return int(self._fn["alloc"](self._store, size)) & 0xFFFFFFFF + + def _dealloc(self, ptr: int, size: int) -> None: + try: + self._fn["dealloc"](self._store, ptr, size) + except Exception: + logger.debug("dealloc failed", exc_info=True) + + def _read_stream(self, name: str) -> str: + length = int(self._fn[f"get_{name}_len"](self._store)) + if length <= 0: + return "" + if length > self._limits.max_output_bytes: + raise PythonExecutionError( + f"{name} size {length} bytes exceeds limit of " + f"{self._limits.max_output_bytes} bytes" + ) + ptr = self._alloc(length) + try: + self._fn[f"get_{name}"](self._store, ptr, length) + data = self._memory.read(self._store, ptr, ptr + length) + return bytes(data).decode("utf-8", errors="replace") + finally: + self._dealloc(ptr, length) + + def _try_read_stderr(self) -> str: + try: + return self._read_stream("stderr") + except Exception: + return "" + + # ------------------------------------------------------------------ + # Host functions + + def host_function(self, name: str): + """Decorator registering a host function callable from guest code via + boomslang_host.call(name, args_json).""" + + def decorator(fn: HostFunction) -> HostFunction: + self._host_functions[name] = fn + return fn + + return decorator + + def _dispatch_host_call(self, name: str, args_json: str) -> str: + fn = self._host_functions.get(name) + if fn is not None: + args = json.loads(args_json) if args_json else None + return json.dumps(fn(args)) + if self._call_handler is not None: + return self._call_handler(name, args_json) + raise KeyError(f"no host function registered for {name!r}") + + # ------------------------------------------------------------------ + # Introspection + + @property + def work_dir(self) -> Path: + """Host path mounted at /work inside the guest.""" + return self._work_dir + + @property + def lib_dir(self) -> Path: + """Host path mounted at /lib inside the guest (on the guest's sys.path).""" + return self._lib_dir + + @property + def guest_work_path(self) -> str: + return GUEST_WORK_PATH + + @property + def is_closed(self) -> bool: + return self._closed + + @property + def is_poisoned(self) -> bool: + return self._poisoned + + @property + def limits(self) -> ResourceLimits: + return self._limits + + def heap_pages(self) -> int: + """Current size of the guest linear memory in 64 KiB pages.""" + with self._lock: + self._check_usable() + return int(self._fn["get_heap_pages"](self._store)) + + def _check_usable(self) -> None: + if self._closed: + raise SandboxClosedError("Sandbox has been closed") + if self._poisoned: + raise SandboxPoisonedError( + "Sandbox has been poisoned after a timeout or trap — call reset() before reuse" + ) diff --git a/boomslang-py/tests/conftest.py b/boomslang-py/tests/conftest.py new file mode 100644 index 0000000..4113279 --- /dev/null +++ b/boomslang-py/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from boomslang import Sandbox + + +@pytest.fixture(scope="session", autouse=True) +def warm_runtime(): + # Compile/load the module once up front so individual tests don't absorb + # the cold-cache cost. + from boomslang._engine import runtime + + runtime() + + +@pytest.fixture +def sandbox(): + with Sandbox() as sb: + yield sb diff --git a/boomslang-py/tests/test_execute.py b/boomslang-py/tests/test_execute.py new file mode 100644 index 0000000..ef6446e --- /dev/null +++ b/boomslang-py/tests/test_execute.py @@ -0,0 +1,64 @@ +import pytest + +from boomslang import Sandbox, SandboxClosedError + + +def test_hello_world(sandbox): + result = sandbox.execute("print('hello')") + assert result.stdout == "hello\n" + assert result.stderr == "" + assert result.exit_code == 0 + assert result.ok + + +def test_stdout_stderr_split(sandbox): + result = sandbox.execute( + "import sys\nprint('out')\nprint('err', file=sys.stderr)" + ) + assert result.stdout == "out\n" + assert result.stderr == "err\n" + + +def test_python_error_reported_via_exit_code(sandbox): + result = sandbox.execute("1 / 0") + assert not result.ok + assert result.exit_code != 0 + assert "ZeroDivisionError" in result.stderr + + +def test_state_persists_across_execute(sandbox): + sandbox.execute("x = 41") + result = sandbox.execute("print(x + 1)") + assert result.stdout == "42\n" + + +def test_reset_clears_state(sandbox): + sandbox.execute("x = 1") + sandbox.reset() + result = sandbox.execute("print(x)") + assert "NameError" in result.stderr + + +def test_two_sandboxes_are_isolated(): + with Sandbox() as a, Sandbox() as b: + a.execute("secret = 'a-only'") + result = b.execute("print(secret)") + assert "NameError" in result.stderr + + +def test_closed_sandbox_rejects_execute(): + sandbox = Sandbox() + sandbox.close() + with pytest.raises(SandboxClosedError): + sandbox.execute("print(1)") + + +def test_no_stdin(sandbox): + result = sandbox.execute("input()") + assert not result.ok + assert "EOFError" in result.stderr + + +def test_unicode_roundtrip(sandbox): + result = sandbox.execute("print('héllo wörld 🐍')") + assert result.stdout == "héllo wörld 🐍\n" diff --git a/boomslang-py/tests/test_host_functions.py b/boomslang-py/tests/test_host_functions.py new file mode 100644 index 0000000..a98ac51 --- /dev/null +++ b/boomslang-py/tests/test_host_functions.py @@ -0,0 +1,81 @@ +import logging + +from boomslang import Sandbox + +GUEST_CALL = ( + "import json\n" + "from boomslang_host import call\n" + "print(call('echo', json.dumps({'a': 1})))" +) + + +def test_host_function_roundtrip(): + with Sandbox() as sandbox: + + @sandbox.host_function("echo") + def echo(args): + return {"echoed": args} + + result = sandbox.execute(GUEST_CALL) + assert result.stdout == '{"echoed": {"a": 1}}\n', result.stderr + + +def test_host_functions_constructor_arg(): + with Sandbox(host_functions={"echo": lambda args: args}) as sandbox: + result = sandbox.execute(GUEST_CALL) + assert result.stdout == '{"a": 1}\n', result.stderr + + +def test_host_function_error_surfaces_in_guest(): + with Sandbox() as sandbox: + + @sandbox.host_function("boom") + def boom(args): + raise RuntimeError("host-side failure") + + result = sandbox.execute( + "from boomslang_host import call\n" + "try:\n" + " call('boom', '{}')\n" + "except RuntimeError:\n" + " print('guest-caught')" + ) + assert result.stdout == "guest-caught\n", result.stderr + # The sandbox stays healthy afterwards. + assert sandbox.execute("print('ok')").stdout == "ok\n" + + +def test_unregistered_host_function_errors_in_guest(sandbox): + result = sandbox.execute( + "from boomslang_host import call\n" + "try:\n" + " call('nope', '{}')\n" + "except RuntimeError:\n" + " print('guest-caught')" + ) + assert result.stdout == "guest-caught\n", result.stderr + + +def test_raw_call_handler(): + with Sandbox(call_handler=lambda name, args: f'"{name}:{args}"') as sandbox: + result = sandbox.execute( + "from boomslang_host import call\nprint(call('anything', '{}'))" + ) + assert result.stdout == '"anything:{}"\n', result.stderr + + +def test_log_bridges_to_logging(caplog): + with Sandbox() as sandbox: + with caplog.at_level(logging.DEBUG, logger="boomslang.guest"): + sandbox.execute("from boomslang_host import log\nlog(2, 'hi from guest')") + assert any( + record.message == "hi from guest" and record.levelno == logging.INFO + for record in caplog.records + ) + + +def test_custom_on_log(): + seen = [] + with Sandbox(on_log=lambda level, message: seen.append((level, message))) as sandbox: + sandbox.execute("from boomslang_host import log\nlog(3, 'warn msg')") + assert seen == [(3, "warn msg")] diff --git a/boomslang-py/tests/test_limits.py b/boomslang-py/tests/test_limits.py new file mode 100644 index 0000000..d29e80e --- /dev/null +++ b/boomslang-py/tests/test_limits.py @@ -0,0 +1,54 @@ +import pytest + +from boomslang import ( + PythonExecutionError, + PythonTimeoutError, + ResourceLimits, + Sandbox, + SandboxPoisonedError, +) + + +def test_timeout_poisons_and_reset_revives(): + with Sandbox(limits=ResourceLimits(timeout=1.0)) as sandbox: + with pytest.raises(PythonTimeoutError): + sandbox.execute("while True: pass") + assert sandbox.is_poisoned + with pytest.raises(SandboxPoisonedError): + sandbox.execute("print(1)") + sandbox.reset() + assert not sandbox.is_poisoned + assert sandbox.execute("print('revived')").stdout == "revived\n" + + +def test_output_limit(): + with Sandbox(limits=ResourceLimits(max_output_bytes=1024)) as sandbox: + with pytest.raises(PythonExecutionError, match="exceeds limit"): + sandbox.execute("print('x' * 10000)") + + +def test_memory_limit_blocks_large_allocations(): + # The runtime image itself needs ~200 MB; cap total memory modestly above + # that so a large allocation cannot be satisfied. + baseline_pages = _baseline_pages() + cap_bytes = (baseline_pages + 512) * 64 * 1024 # baseline + 32 MiB + with Sandbox(limits=ResourceLimits(max_memory_bytes=cap_bytes)) as sandbox: + result = sandbox.execute( + "try:\n" + " data = bytearray(256 * 1024 * 1024)\n" + "except MemoryError:\n" + " print('memory-error')" + ) + assert result.stdout == "memory-error\n", result.stderr + + +def _baseline_pages() -> int: + with Sandbox() as sandbox: + return sandbox.heap_pages() + + +def test_invalid_limits_rejected(): + with pytest.raises(ValueError): + ResourceLimits(timeout=0) + with pytest.raises(ValueError): + ResourceLimits(max_output_bytes=0) diff --git a/boomslang-py/tests/test_mounts.py b/boomslang-py/tests/test_mounts.py new file mode 100644 index 0000000..9231904 --- /dev/null +++ b/boomslang-py/tests/test_mounts.py @@ -0,0 +1,52 @@ +from boomslang import Sandbox + + +def test_work_mount_roundtrip(tmp_path): + (tmp_path / "input.txt").write_text("from host") + with Sandbox(work_dir=tmp_path) as sandbox: + result = sandbox.execute( + "print(open('/work/input.txt').read())\n" + "open('/work/output.txt', 'w').write('from guest')" + ) + assert result.stdout == "from host\n", result.stderr + assert (tmp_path / "output.txt").read_text() == "from guest" + + +def test_managed_work_dir(sandbox): + sandbox.execute("open('/work/file.txt', 'w').write('data')") + assert (sandbox.work_dir / "file.txt").read_text() == "data" + + +def test_work_files_survive_reset(sandbox): + sandbox.execute("open('/work/keep.txt', 'w').write('kept')") + sandbox.reset() + result = sandbox.execute("print(open('/work/keep.txt').read())") + assert result.stdout == "kept\n", result.stderr + + +def test_lib_dir_module_import(tmp_path): + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + (lib_dir / "mylib.py").write_text("def greet():\n return 'hi from /lib'\n") + with Sandbox(lib_dir=lib_dir) as sandbox: + result = sandbox.execute("import mylib\nprint(mylib.greet())") + assert result.stdout == "hi from /lib\n", result.stderr + + +def test_stdlib_is_readonly(sandbox): + result = sandbox.execute( + "try:\n" + " open('/usr/local/lib/python3.14/evil.py', 'w').write('x')\n" + " print('wrote')\n" + "except OSError:\n" + " print('blocked')" + ) + assert result.stdout == "blocked\n", result.stderr + + +def test_tmp_is_writable(sandbox): + result = sandbox.execute( + "open('/tmp/scratch.txt', 'w').write('tmp')\n" + "print(open('/tmp/scratch.txt').read())" + ) + assert result.stdout == "tmp\n", result.stderr diff --git a/boomslang-py/tests/test_packages.py b/boomslang-py/tests/test_packages.py new file mode 100644 index 0000000..9c3b972 --- /dev/null +++ b/boomslang-py/tests/test_packages.py @@ -0,0 +1,43 @@ +def test_non_prewarmed_stdlib_import(sandbox): + # Pick a module that is NOT already in sys.modules (i.e. not pre-imported + # at Wizer time) so this genuinely exercises filesystem-based module + # loading through the read-only /usr preopen. + result = sandbox.execute( + "import sys\n" + "assert 'wave' not in sys.modules, 'wave was pre-imported; pick another module'\n" + "import wave\n" + "print(wave.Error.__name__)" + ) + assert result.stdout == "Error\n", result.stderr + + +def test_numpy(sandbox): + result = sandbox.execute( + "import numpy as np\nprint(int(np.array([1, 2, 3]).sum()))" + ) + assert result.stdout == "6\n", result.stderr + + +def test_pydantic(sandbox): + result = sandbox.execute( + "from pydantic import BaseModel\n" + "class User(BaseModel):\n" + " name: str\n" + " age: int\n" + "print(User(name='Ada', age=36).model_dump_json())" + ) + assert result.stdout == '{"name":"Ada","age":36}\n', result.stderr + + +def test_deep_recursion(sandbox): + # Canary for the native wasm stack size: CPython recursion should hit + # RecursionError (a normal Python error), not a stack-overflow trap. + result = sandbox.execute( + "def f(n):\n" + " return f(n + 1)\n" + "try:\n" + " f(0)\n" + "except RecursionError:\n" + " print('recursion-error')" + ) + assert result.stdout == "recursion-error\n", result.stderr diff --git a/justfile b/justfile index 8061022..9c23a81 100644 --- a/justfile +++ b/justfile @@ -126,6 +126,32 @@ build: test: ./mill test +# ============================================================ +# Python package (boomslang-py) +# ============================================================ + +# Stage runtime resources (WASM + stdlib) into the Python package +python-stage: + ./scripts/stage-python-runtime.sh + +# Run the Python package test suite against staged runtime resources +python-test: python-stage + #!/usr/bin/env bash + set -euo pipefail + cd boomslang-py + if command -v uv >/dev/null 2>&1; then + uv venv .venv --allow-existing + uv pip install --python .venv/bin/python -q -e '.[dev]' + else + python3 -m venv .venv + .venv/bin/pip install -q -e '.[dev]' + fi + .venv/bin/pytest + +# Build the Python wheel (bundles WASM + stdlib) +python-wheel: python-stage + ./scripts/build-python-wheel.sh + # ============================================================ # Individual WASM step shortcuts # ============================================================ diff --git a/scripts/build-python-wheel.sh b/scripts/build-python-wheel.sh new file mode 100755 index 0000000..17f7ae5 --- /dev/null +++ b/scripts/build-python-wheel.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +pkg_dir="$repo_root/boomslang-py" +version_file="$pkg_dir/src/boomslang/_version.py" + +if [ ! -d "$pkg_dir/src/boomslang/_runtime" ]; then + echo "ERROR: runtime assets are not staged. Run scripts/stage-python-runtime.sh first." >&2 + exit 1 +fi + +resolve_version() { + if [ -n "${BOOMSLANG_WHEEL_VERSION:-}" ]; then + echo "$BOOMSLANG_WHEEL_VERSION" + return + fi + local tag + tag="$(git -C "$repo_root" describe --tags --exact-match 2>/dev/null || true)" + if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${tag#v}" + return + fi + local sha + sha="$(git -C "$repo_root" rev-parse --short=12 HEAD)" + echo "0.0.0+g$sha" +} + +version="$(resolve_version)" +echo "Building boomslang wheel version $version" + +original_version_file="$(cat "$version_file")" +restore_version_file() { + printf '%s' "$original_version_file" > "$version_file" +} +trap restore_version_file EXIT + +printf '__version__ = "%s"\n' "$version" > "$version_file" + +cd "$pkg_dir" +rm -rf dist +if command -v uv >/dev/null 2>&1; then + uv build --wheel +else + python3 -m build --wheel +fi + +wheel="$(ls dist/*.whl)" +echo "Built $wheel ($(du -h "$wheel" | cut -f1))" diff --git a/scripts/stage-python-runtime.sh b/scripts/stage-python-runtime.sh new file mode 100755 index 0000000..890d414 --- /dev/null +++ b/scripts/stage-python-runtime.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="$repo_root/core/src/main/resources/python" +overlay_root="$repo_root/core/src/main/resources/python-overlay" +dest="$repo_root/boomslang-py/src/boomslang/_runtime" + +usage() { + cat <<'EOF' +Usage: scripts/stage-python-runtime.sh + +Stage the built runtime resources (WASM binary + Python stdlib tree) into +boomslang-py/src/boomslang/_runtime so the Python wheel can bundle them. + +Sources: + core/src/main/resources/python/{bin,usr} (from `just resources` or `just fetch-main-wasm`) + core/src/main/resources/python-overlay/ (checked in, merged over the stdlib tree) +EOF +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +if [ ! -f "$runtime_root/bin/boomslang.wasm" ]; then + echo "ERROR: $runtime_root/bin/boomslang.wasm not found." >&2 + echo "Run 'just fetch-main-wasm' (or 'just resources' after a local build) first." >&2 + exit 1 +fi + +if [ ! -d "$runtime_root/usr/local/lib/python3.14" ]; then + echo "ERROR: $runtime_root/usr/local/lib/python3.14 not found." >&2 + echo "Run 'just fetch-main-wasm' (or 'just resources' after a local build) first." >&2 + exit 1 +fi + +mkdir -p "$dest" +rsync -a --delete \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + "$runtime_root/bin" "$runtime_root/usr" "$dest/" + +if [ -d "$overlay_root" ]; then + rsync -a \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + "$overlay_root/" "$dest/" +fi + +python3 - "$dest" <<'PY' +import pathlib +import sys + +dest = pathlib.Path(sys.argv[1]) +wasm = dest / "bin" / "boomslang.wasm" +stdlib = dest / "usr" / "local" / "lib" / "python3.14" + +if wasm.read_bytes()[:4] != b"\0asm": + raise SystemExit("staged boomslang.wasm is not a WASM binary") +if not stdlib.is_dir(): + raise SystemExit("staged Python stdlib tree is missing") +if not (stdlib / "boomslang_host" / "__init__.py").is_file(): + raise SystemExit("staged stdlib is missing boomslang_host/__init__.py") +leftover = next(stdlib.rglob("__pycache__"), None) +if leftover is not None: + raise SystemExit(f"staged stdlib contains {leftover}") +PY + +wasm_size="$(wc -c < "$dest/bin/boomslang.wasm" | tr -d ' ')" +stdlib_paths="$(find "$dest/usr/local/lib/python3.14" | wc -l | tr -d ' ')" + +echo "Staged Python runtime into $dest" +echo " wasm: $dest/bin/boomslang.wasm ($wasm_size bytes)" +echo " stdlib: $dest/usr/local/lib/python3.14 ($stdlib_paths paths)" From 8c97b7a0b6a63c6ba5779323505108bf7b131e95 Mon Sep 17 00:00:00 2001 From: Ze'ev Klapow Date: Wed, 10 Jun 2026 11:08:01 -0400 Subject: [PATCH 2/4] Add async host functions, bytecode, stdin, large results --- boomslang-py/README.md | 58 +++- boomslang-py/src/boomslang/__init__.py | 2 + boomslang-py/src/boomslang/_async.py | 205 ++++++++++++ boomslang-py/src/boomslang/_trampolines.py | 9 +- boomslang-py/src/boomslang/errors.py | 4 + boomslang-py/src/boomslang/sandbox.py | 311 ++++++++++++++++-- .../tests/test_async_host_functions.py | 124 +++++++ boomslang-py/tests/test_bytecode.py | 42 +++ boomslang-py/tests/test_host_functions.py | 53 +++ boomslang-py/tests/test_stdin.py | 35 ++ 10 files changed, 800 insertions(+), 43 deletions(-) create mode 100644 boomslang-py/src/boomslang/_async.py create mode 100644 boomslang-py/tests/test_async_host_functions.py create mode 100644 boomslang-py/tests/test_bytecode.py create mode 100644 boomslang-py/tests/test_stdin.py diff --git a/boomslang-py/README.md b/boomslang-py/README.md index ea1c9c8..49ca568 100644 --- a/boomslang-py/README.md +++ b/boomslang-py/README.md @@ -66,13 +66,23 @@ preopen table is baked in at build time): additional mount points are not supported — share files through `/work`, and make extra pure-Python libraries importable by placing them in `lib_dir`. -There is no stdin: `input()` raises `EOFError`. +## Stdin + +```python +sandbox.set_stdin("Ada\n") +sandbox.execute("print('hello', input())") +``` + +Mirroring the Java host, stdin is consumed by the next execution and then +cleared — call `set_stdin()` before each execution that needs it. Without it, +`input()` raises `EOFError`. ## Host functions Guest code can call back into your process through the bundled -`boomslang_host` bridge. Arguments and results cross the boundary as JSON; -results are capped at 1 MiB by the guest-side buffer. +`boomslang_host` bridge. Arguments and results cross the boundary as JSON. +Results larger than the bridge's native 1 MiB buffer are transparently +fetched back in chunks, so there is no practical size cap. ```python sandbox = Sandbox() @@ -94,6 +104,48 @@ strings in and out), and `on_log=lambda level, message: ...` to receive `boomslang_host.log()` output (default: forwarded to the `boomslang.guest` logger). +### Async host functions + +Async handlers run on a host thread pool, so guest coroutines can overlap +slow host work (I/O, RPCs) via the bundled `boomslang_host.asyncio` event +loop (the same wire protocol as the Java `AsyncHostRegistry`): + +```python +sandbox = Sandbox() + +@sandbox.async_host_function("fetch") +def fetch(args): # runs on a host worker thread + return {"id": args["id"], "name": "Ada"} + +result = sandbox.execute(""" +import asyncio, json +from boomslang_host.asyncio import async_call + +async def main(): + a, b = await asyncio.gather( + async_call("fetch", json.dumps({"id": 1})), + async_call("fetch", json.dumps({"id": 2})), + ) + print(json.loads(a)["name"], json.loads(b)["name"]) + +asyncio.run(main()) +""") +``` + +The execute timeout still applies while the guest is awaiting. + +## Bytecode and function calls + +`compile()` produces bytecode you can cache and re-run (also in other +sandboxes), skipping repeated parsing; `execute_function()` calls a function +defined in the guest's `__main__` with a JSON array of positional arguments: + +```python +bytecode = sandbox.compile("def add(a, b):\n print(a + b)") +sandbox.load_bytecode(bytecode) +sandbox.execute_function("add", "[2, 40]") # prints 42 +``` + ## Performance notes - The first `Sandbox()` ever created on a machine compiles the ~100 MB WASM diff --git a/boomslang-py/src/boomslang/__init__.py b/boomslang-py/src/boomslang/__init__.py index 54224c6..2b6ad7e 100644 --- a/boomslang-py/src/boomslang/__init__.py +++ b/boomslang-py/src/boomslang/__init__.py @@ -12,6 +12,7 @@ from ._version import __version__ from .errors import ( BoomslangError, + PythonCompilationError, PythonExecutionError, PythonTimeoutError, RuntimeAssetsError, @@ -25,6 +26,7 @@ __all__ = [ "BoomslangError", "ExecutionResult", + "PythonCompilationError", "PythonExecutionError", "PythonTimeoutError", "ResourceLimits", diff --git a/boomslang-py/src/boomslang/_async.py b/boomslang-py/src/boomslang/_async.py new file mode 100644 index 0000000..2c3bc57 --- /dev/null +++ b/boomslang-py/src/boomslang/_async.py @@ -0,0 +1,205 @@ +"""Host side of the async bridge for boomslang_host.asyncio. + +Mirrors the Java AsyncHostRegistry wire protocol (v1), spoken over the stock +boomslang_host.call function with reserved control names: + + __async_protocol__ -> "1" + __async_start__ name\\nargs -> decimal token (always positive) + __async_poll__ timeout_ms -> "token\\t{1|0}\\t\\n" per completion + __async_result__ token -> base64 of the completion's value bytes (consuming) + __async_cancel__ token -> cancels the in-flight call + +Values are fetched one at a time so a batch of completions never exceeds the +single host-call result buffer. +""" + +import base64 +import queue +import threading +import time +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor + +PROTOCOL_VERSION = 1 + +PROTOCOL = "__async_protocol__" +START = "__async_start__" +POLL = "__async_poll__" +RESULT = "__async_result__" +CANCEL = "__async_cancel__" + +_CONTROL_NAMES = frozenset({PROTOCOL, START, POLL, RESULT, CANCEL}) + +# Blocking polls are sliced so the wasm thread regularly returns to guest code, +# where the epoch-deadline trap can fire if the execute timeout has passed. +_POLL_SLICE_SECONDS = 0.05 + + +class _Completion: + __slots__ = ("token", "ok", "value") + + def __init__(self, token: int, ok: bool, value: bytes): + self.token = token + self.ok = ok + self.value = value + + @classmethod + def from_result(cls, token: int, result) -> "_Completion": + text = "" if result is None else str(result) + return cls(token, True, text.encode("utf-8")) + + @classmethod + def from_error(cls, token: int, error: BaseException) -> "_Completion": + return cls(token, False, repr(error).encode("utf-8")) + + +class AsyncHostRegistry: + """Runs async host handlers on a thread pool and queues their completions + for the guest event loop to poll.""" + + def __init__(self, deadline_remaining: Callable[[], float | None]): + self._handlers: dict[str, Callable[[str], str]] = {} + self._executor: ThreadPoolExecutor | None = None + self._next_token = 1 + self._token_lock = threading.Lock() + self._in_flight: dict[int, Future] = {} + self._completions: queue.Queue[_Completion] = queue.Queue() + self._ready: dict[int, _Completion] = {} + # Returns seconds until the current execute deadline (None = no deadline); + # blocking polls never sleep past it so the guest can hit its epoch trap. + self._deadline_remaining = deadline_remaining + + def register(self, name: str, handler: Callable[[str], str]) -> None: + if name in _CONTROL_NAMES or name.startswith("__"): + raise ValueError(f"async host function name is reserved: {name!r}") + self._handlers[name] = handler + + @property + def has_handlers(self) -> bool: + return bool(self._handlers) + + def is_control_call(self, name: str) -> bool: + return name in _CONTROL_NAMES + + def handle_control_call(self, name: str, args: str) -> str: + if name == PROTOCOL: + return str(PROTOCOL_VERSION) + if name == START: + return self._start(args) + if name == POLL: + return self._poll(int(args.strip())) + if name == RESULT: + return self._result(int(args.strip())) + if name == CANCEL: + self._cancel(int(args.strip())) + return "" + raise RuntimeError(f"Unknown async control call: {name}") + + def close(self) -> None: + for future in list(self._in_flight.values()): + future.cancel() + self._in_flight.clear() + if self._executor is not None: + self._executor.shutdown(wait=False, cancel_futures=True) + self._executor = None + + # ------------------------------------------------------------------ + + def _allocate_token(self) -> int: + with self._token_lock: + token = self._next_token + self._next_token += 1 + return token + + def _start(self, args: str) -> str: + name, _, payload = args.partition("\n") + handler = self._handlers.get(name) + token = self._allocate_token() + if handler is None: + self._completions.put( + _Completion.from_error( + token, RuntimeError(f"No async handler registered for: {name}") + ) + ) + return str(token) + + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=8, thread_name_prefix="boomslang-async" + ) + future = self._executor.submit(handler, payload) + self._in_flight[token] = future + + def on_done(done: Future, token: int = token) -> None: + self._in_flight.pop(token, None) + if done.cancelled(): + self._completions.put( + _Completion.from_error(token, RuntimeError("cancelled")) + ) + return + error = done.exception() + if error is not None: + self._completions.put(_Completion.from_error(token, error)) + else: + self._completions.put(_Completion.from_result(token, done.result())) + + future.add_done_callback(on_done) + return str(token) + + def _poll(self, timeout_ms: int) -> str: + drained: list[_Completion] = [] + first = self._take_first(timeout_ms) + if first is not None: + drained.append(first) + while True: + try: + drained.append(self._completions.get_nowait()) + except queue.Empty: + break + + headers = [] + for completion in drained: + self._ready[completion.token] = completion + ok = "1" if completion.ok else "0" + headers.append(f"{completion.token}\t{ok}\t{len(completion.value)}\n") + return "".join(headers) + + def _take_first(self, timeout_ms: int) -> _Completion | None: + if timeout_ms == 0: + try: + return self._completions.get_nowait() + except queue.Empty: + return None + + wait_forever = timeout_ms < 0 + deadline = None if wait_forever else time.monotonic() + timeout_ms / 1000 + while True: + slice_s = _POLL_SLICE_SECONDS + remaining_execute = self._deadline_remaining() + if remaining_execute is not None: + if remaining_execute <= 0: + # The execute timeout has passed; hand control back to the + # guest so the epoch trap can fire. + return None + slice_s = min(slice_s, remaining_execute) + if deadline is not None: + remaining_poll = deadline - time.monotonic() + if remaining_poll <= 0: + return None + slice_s = min(slice_s, remaining_poll) + try: + return self._completions.get(timeout=slice_s) + except queue.Empty: + continue + + def _result(self, token: int) -> str: + completion = self._ready.pop(token, None) + if completion is None: + return "" + return base64.b64encode(completion.value).decode("ascii") + + def _cancel(self, token: int) -> None: + self._ready.pop(token, None) + future = self._in_flight.pop(token, None) + if future is not None: + future.cancel() diff --git a/boomslang-py/src/boomslang/_trampolines.py b/boomslang-py/src/boomslang/_trampolines.py index 8b00c7b..b55e0e7 100644 --- a/boomslang-py/src/boomslang/_trampolines.py +++ b/boomslang-py/src/boomslang/_trampolines.py @@ -37,12 +37,9 @@ def call(caller, name_ptr, name_len, args_ptr, args_len, result_ptr, result_max_ result = sandbox._dispatch_host_call(name, args) data = result.encode("utf-8") if len(data) > result_max_len: - logger.error( - "host function %r result is %d bytes, exceeding the guest's %d-byte buffer", - name, - len(data), - result_max_len, - ) + # Park the result so the guest-side call() wrapper can fetch + # it back in chunks via __result_pending__/__result_chunk__. + sandbox._park_oversized_result(data) return CALL_RESULT_TOO_LARGE memory.write(caller, data, result_ptr) return len(data) diff --git a/boomslang-py/src/boomslang/errors.py b/boomslang-py/src/boomslang/errors.py index 80ca997..b22b8af 100644 --- a/boomslang-py/src/boomslang/errors.py +++ b/boomslang-py/src/boomslang/errors.py @@ -14,6 +14,10 @@ class PythonExecutionError(BoomslangError): """ +class PythonCompilationError(BoomslangError): + """compile() failed — Python syntax error or oversized bytecode.""" + + class PythonTimeoutError(BoomslangError): """Guest execution exceeded ResourceLimits.timeout. The sandbox is poisoned until reset() is called.""" diff --git a/boomslang-py/src/boomslang/sandbox.py b/boomslang-py/src/boomslang/sandbox.py index a7c1005..8b6567e 100644 --- a/boomslang-py/src/boomslang/sandbox.py +++ b/boomslang-py/src/boomslang/sandbox.py @@ -1,3 +1,4 @@ +import base64 import json import logging import os @@ -12,9 +13,11 @@ from wasmtime import DirPerms, FilePerms, Linker, Store, WasiConfig from ._assets import usr_host_dir +from ._async import AsyncHostRegistry from ._engine import DISARMED_DEADLINE_TICKS, runtime from ._trampolines import define_boomslang_imports from .errors import ( + PythonCompilationError, PythonExecutionError, PythonTimeoutError, SandboxClosedError, @@ -31,10 +34,22 @@ GUEST_WORK_PATH = "/work" +MAX_BYTECODE_SIZE = 10 * 1024 * 1024 + +# Reserved control names for fetching host-call results larger than the +# guest bridge's fixed 1 MiB buffer (see _GUEST_BOOTSTRAP below). +_RESULT_PENDING = "__result_pending__" +_RESULT_CHUNK = "__result_chunk__" +# Base64 chunk size; must stay comfortably under the guest's 1 MiB buffer. +_RESULT_CHUNK_SIZE = 768 * 1024 + _EXPORT_NAMES = ( "alloc", "dealloc", "execute", + "compile_source", + "load_bytecode", + "execute_function", "reset_state", "get_stdout_len", "get_stderr_len", @@ -45,6 +60,60 @@ _LOG_LEVELS = {0: logging.DEBUG, 1: logging.DEBUG, 2: logging.INFO, 3: logging.WARNING} +# Wraps boomslang_host.call so results larger than the guest bridge's fixed +# 1 MiB native buffer are fetched in base64 chunks through the reserved +# __result_pending__/__result_chunk__ control calls. The wrapper is installed +# by monkeypatching because the prewarmed boomslang_host module (and the +# `call` reference captured by boomslang_host.asyncio) is frozen into the +# Wizer memory snapshot — overriding the .py file on disk would not be seen. +# Under hosts without the control calls (e.g. the Java host) the pending probe +# fails and the original error is re-raised, preserving stock behavior. +_GUEST_BOOTSTRAP = """ +def __boomslang_install(): + import base64 + import boomslang_host + if hasattr(boomslang_host, "_boomslang_native_call"): + return + native = boomslang_host.call + boomslang_host._boomslang_native_call = native + + def patched(name, args=""): + try: + return native(name, args) + except RuntimeError: + try: + header = native("__result_pending__", "") + except RuntimeError: + header = "" + if not header: + raise + total = int(header) + parts = [] + offset = 0 + while offset < total: + part = native("__result_chunk__", str(offset)) + if not part: + raise RuntimeError("host call failed (truncated chunked result)") + parts.append(part) + offset += len(part) + return base64.b64decode("".join(parts)).decode("utf-8") + + boomslang_host.call = patched + try: + import boomslang_host.asyncio + boomslang_host.asyncio.call = patched + except Exception: + pass + + +__boomslang_install() +del __boomslang_install +""" + +_CLEAR_STDIN_SCRIPT = ( + "import sys, io\nsys.stdin = io.TextIOWrapper(io.BytesIO(b''), encoding='utf-8')" +) + def _default_on_log(level: int, message: str) -> None: guest_logger.log(_LOG_LEVELS.get(level, logging.ERROR), "%s", message) @@ -63,7 +132,11 @@ class Sandbox: from boomslang_host import call, log call("my_function", '{"key": "value"}') # JSON in, JSON out - Host-function results are capped at 1 MiB by the guest-side bridge buffer. + Async host functions are awaited through the bundled event loop: + + import asyncio + from boomslang_host.asyncio import async_call + asyncio.run(async_call("my_async_function", '{"key": "value"}')) The guest filesystem layout is fixed by the runtime image (the libc preopen table is baked in at Wizer time, bound positionally to the host's @@ -83,6 +156,7 @@ def __init__( work_dir: str | os.PathLike | None = None, lib_dir: str | os.PathLike | None = None, host_functions: Mapping[str, HostFunction] | None = None, + async_host_functions: Mapping[str, HostFunction] | None = None, call_handler: Callable[[str, str], str] | None = None, on_log: Callable[[int, str], None] | None = None, python_path: Sequence[str] = (GUEST_WORK_PATH,), @@ -95,6 +169,12 @@ def __init__( self._lock = threading.RLock() self._closed = False self._poisoned = False + self._stdin_armed = False + self._deadline_at: float | None = None + self._parked_result: str | None = None + self._async_registry = AsyncHostRegistry(self._deadline_remaining) + for name, fn in (async_host_functions or {}).items(): + self._register_async(name, fn) self._scratch = tempfile.TemporaryDirectory(prefix="boomslang-scratch-") (Path(self._scratch.name) / "tmp").mkdir() @@ -152,19 +232,20 @@ def _instantiate(self) -> None: self._store = store self._memory = exports["memory"] self._fn = {name: exports[name] for name in _EXPORT_NAMES} + self._stdin_armed = False - self._bootstrap_python_path() + self._bootstrap() - def _bootstrap_python_path(self) -> None: - if not self._python_path: - return - script = "import sys" - for entry in self._python_path: - script += f"\nsys.path.insert(0, {entry!r})" - status = self._call_execute(script) + def _bootstrap(self) -> None: + script = _GUEST_BOOTSTRAP + if self._python_path: + script += "\nimport sys" + for entry in self._python_path: + script += f"\nsys.path.insert(0, {entry!r})" + status = self._invoke_script("execute", script) if status != 0: logger.warning( - "python_path injection failed with code %s: %s", + "sandbox bootstrap failed with code %s: %s", status, self._read_stream("stderr"), ) @@ -185,6 +266,7 @@ def close(self) -> None: self._fn = {} self._memory = None self._store = None + self._async_registry.close() self._scratch.cleanup() if self._managed_work is not None: self._managed_work.cleanup() @@ -202,41 +284,160 @@ def execute(self, code: str) -> ExecutionResult: with self._lock: self._check_usable() start = time.perf_counter() - exit_code = self._call_execute(code) - stdout = self._read_stream("stdout") - stderr = self._read_stream("stderr") - duration_ms = (time.perf_counter() - start) * 1000 - return ExecutionResult( - stdout=stdout, stderr=stderr, exit_code=exit_code, duration_ms=duration_ms - ) + exit_code = self._invoke_script("execute", code) + return self._collect_result(exit_code, start) + + def execute_function(self, name: str, args_json: str = "") -> ExecutionResult: + """Call a function defined in the guest's __main__. args_json must be a + JSON array of positional arguments (e.g. "[2, 40]") or empty.""" + with self._lock: + self._check_usable() + start = time.perf_counter() + name_data = name.encode("utf-8") + args_data = args_json.encode("utf-8") + name_ptr = self._alloc(len(name_data)) + args_ptr = self._alloc(len(args_data)) if args_data else 0 + try: + self._memory.write(self._store, name_data, name_ptr) + if args_data: + self._memory.write(self._store, args_data, args_ptr) + exit_code = self._invoke( + "execute_function", name_ptr, len(name_data), args_ptr, len(args_data) + ) + finally: + self._dealloc(name_ptr, len(name_data)) + if args_ptr: + self._dealloc(args_ptr, len(args_data)) + return self._collect_result(exit_code, start) + + def compile(self, code: str) -> bytes: + """Compile Python source to bytecode that can be re-run via load_bytecode().""" + with self._lock: + self._check_usable() + data = code.encode("utf-8") + source_ptr = self._alloc(len(data)) + output_ptr = self._alloc(MAX_BYTECODE_SIZE) + try: + self._memory.write(self._store, data, source_ptr) + bytecode_len = self._invoke( + "compile_source", source_ptr, len(data), output_ptr, MAX_BYTECODE_SIZE + ) + if bytecode_len < 0: + stderr = self._try_read_stderr() + message = stderr or f"compilation failed with code {bytecode_len}" + if bytecode_len == -3: + message = f"compiled bytecode exceeds {MAX_BYTECODE_SIZE} bytes" + raise PythonCompilationError(message) + data_out = self._memory.read( + self._store, output_ptr, output_ptr + bytecode_len + ) + return bytes(data_out) + finally: + self._dealloc(source_ptr, len(data)) + self._dealloc(output_ptr, MAX_BYTECODE_SIZE) + + def load_bytecode(self, bytecode: bytes) -> ExecutionResult: + """Execute bytecode previously produced by compile().""" + with self._lock: + self._check_usable() + start = time.perf_counter() + ptr = self._alloc(len(bytecode)) + try: + self._memory.write(self._store, bytecode, ptr) + exit_code = self._invoke("load_bytecode", ptr, len(bytecode)) + finally: + self._dealloc(ptr, len(bytecode)) + return self._collect_result(exit_code, start) - def _call_execute(self, code: str) -> int: + # ------------------------------------------------------------------ + # Stdin + + def set_stdin(self, data: bytes | str) -> None: + """Provide stdin for the next execute()/execute_function()/load_bytecode() + call. Mirrors the Java host: stdin is cleared after each execution.""" + if isinstance(data, str): + data = data.encode("utf-8") + encoded = base64.b64encode(data).decode("ascii") + script = ( + "import sys, io, base64\n" + f"sys.stdin = io.TextIOWrapper(io.BytesIO(base64.b64decode('{encoded}')), " + "encoding='utf-8')" + ) + with self._lock: + self._check_usable() + status = self._invoke_script("execute", script) + if status != 0: + raise PythonExecutionError( + self._try_read_stderr() or "failed to set stdin" + ) + self._stdin_armed = True + + def clear_stdin(self) -> None: + with self._lock: + self._check_usable() + self._clear_stdin_locked() + + def _clear_stdin_locked(self) -> None: + if not self._stdin_armed: + return + self._stdin_armed = False + status = self._invoke_script("execute", _CLEAR_STDIN_SCRIPT) + if status != 0: + logger.warning("failed to clear sandbox stdin: %s", self._try_read_stderr()) + + # ------------------------------------------------------------------ + # Guest invocation plumbing + + def _collect_result(self, exit_code: int, start: float) -> ExecutionResult: + stdout = self._read_stream("stdout") + stderr = self._read_stream("stderr") + self._clear_stdin_locked() + duration_ms = (time.perf_counter() - start) * 1000 + return ExecutionResult( + stdout=stdout, stderr=stderr, exit_code=exit_code, duration_ms=duration_ms + ) + + def _invoke_script(self, fn_name: str, code: str) -> int: data = code.encode("utf-8") ptr = self._alloc(len(data)) try: self._memory.write(self._store, data, ptr) - self._store.set_epoch_deadline(runtime().deadline_ticks(self._limits.timeout)) - try: - return int(self._fn["execute"](self._store, ptr, len(data))) - except wasmtime.Trap as trap: - self._poisoned = True - self._disarm_deadline() - if trap.trap_code == wasmtime.TrapCode.INTERRUPT: - raise PythonTimeoutError( - f"execution exceeded the {self._limits.timeout}s timeout; " - "the sandbox is poisoned until reset()" - ) from trap - stderr = self._try_read_stderr() - message = stderr or trap.message - raise PythonExecutionError(message) from trap - finally: - self._disarm_deadline() + return self._invoke(fn_name, ptr, len(data)) finally: self._dealloc(ptr, len(data)) + def _invoke(self, fn_name: str, *args: int) -> int: + """Call a guest export with the execute deadline armed, mapping traps.""" + self._arm_deadline() + try: + return int(self._fn[fn_name](self._store, *args)) + except wasmtime.Trap as trap: + self._poisoned = True + self._disarm_deadline() + if trap.trap_code == wasmtime.TrapCode.INTERRUPT: + raise PythonTimeoutError( + f"execution exceeded the {self._limits.timeout}s timeout; " + "the sandbox is poisoned until reset()" + ) from trap + stderr = self._try_read_stderr() + raise PythonExecutionError(stderr or trap.message) from trap + finally: + self._disarm_deadline() + + def _arm_deadline(self) -> None: + self._deadline_at = time.monotonic() + self._limits.timeout + self._store.set_epoch_deadline(runtime().deadline_ticks(self._limits.timeout)) + def _disarm_deadline(self) -> None: + self._deadline_at = None self._store.set_epoch_deadline(DISARMED_DEADLINE_TICKS) + def _deadline_remaining(self) -> float | None: + deadline = self._deadline_at + if deadline is None: + return None + return deadline - time.monotonic() + def _alloc(self, size: int) -> int: return int(self._fn["alloc"](self._store, size)) & 0xFFFFFFFF @@ -282,7 +483,35 @@ def decorator(fn: HostFunction) -> HostFunction: return decorator + def async_host_function(self, name: str): + """Decorator registering an async host function. The handler runs on a + host thread pool; guest code awaits it via + boomslang_host.asyncio.async_call(name, args_json) under asyncio.run().""" + + def decorator(fn: HostFunction) -> HostFunction: + self._register_async(name, fn) + return fn + + return decorator + + def _register_async(self, name: str, fn: HostFunction) -> None: + def handler(payload: str) -> str: + args = json.loads(payload) if payload else None + return json.dumps(fn(args)) + + self._async_registry.register(name, handler) + def _dispatch_host_call(self, name: str, args_json: str) -> str: + if name == _RESULT_PENDING: + parked = self._parked_result + return "" if parked is None else str(len(parked)) + if name == _RESULT_CHUNK: + return self._read_parked_chunk(int(args_json.strip())) + if self._async_registry.is_control_call(name): + return self._async_registry.handle_control_call(name, args_json) + + # A new user-level call invalidates any unfetched oversized result. + self._parked_result = None fn = self._host_functions.get(name) if fn is not None: args = json.loads(args_json) if args_json else None @@ -291,6 +520,20 @@ def _dispatch_host_call(self, name: str, args_json: str) -> str: return self._call_handler(name, args_json) raise KeyError(f"no host function registered for {name!r}") + def _park_oversized_result(self, data: bytes) -> None: + """Called by the trampoline when a result exceeds the guest's buffer; + the guest fetches it back in chunks via __result_pending__/__result_chunk__.""" + self._parked_result = base64.b64encode(data).decode("ascii") + + def _read_parked_chunk(self, offset: int) -> str: + parked = self._parked_result + if parked is None: + return "" + chunk = parked[offset : offset + _RESULT_CHUNK_SIZE] + if offset + _RESULT_CHUNK_SIZE >= len(parked): + self._parked_result = None + return chunk + # ------------------------------------------------------------------ # Introspection diff --git a/boomslang-py/tests/test_async_host_functions.py b/boomslang-py/tests/test_async_host_functions.py new file mode 100644 index 0000000..7e99f57 --- /dev/null +++ b/boomslang-py/tests/test_async_host_functions.py @@ -0,0 +1,124 @@ +import time + +import pytest + +from boomslang import PythonTimeoutError, ResourceLimits, Sandbox + +ASYNC_ROUNDTRIP = """ +import asyncio +import json +from boomslang_host.asyncio import async_call + +async def main(): + result = await async_call("fetch", json.dumps({"id": 7})) + print(json.loads(result)["name"]) + +asyncio.run(main()) +""" + +ASYNC_CONCURRENT = """ +import asyncio +import json +from boomslang_host.asyncio import async_call + +async def main(): + results = await asyncio.gather( + async_call("slow_echo", json.dumps("a")), + async_call("slow_echo", json.dumps("b")), + async_call("slow_echo", json.dumps("c")), + ) + print(",".join(json.loads(r) for r in results)) + +asyncio.run(main()) +""" + +ASYNC_ERROR = """ +import asyncio +from boomslang_host.asyncio import async_call, HostAsyncError + +async def main(): + try: + await async_call("boom", "{}") + except HostAsyncError: + print("caught") + +asyncio.run(main()) +""" + + +def test_async_roundtrip(): + with Sandbox(async_host_functions={"fetch": lambda args: {"name": "Ada", "id": args["id"]}}) as sandbox: + result = sandbox.execute(ASYNC_ROUNDTRIP) + assert result.stdout == "Ada\n", result.stderr + + +def test_async_decorator(): + with Sandbox() as sandbox: + + @sandbox.async_host_function("fetch") + def fetch(args): + return {"name": "Ada"} + + result = sandbox.execute(ASYNC_ROUNDTRIP) + assert result.stdout == "Ada\n", result.stderr + + +def test_async_calls_run_concurrently(): + def slow_echo(value): + time.sleep(0.3) + return value + + with Sandbox(async_host_functions={"slow_echo": slow_echo}) as sandbox: + start = time.monotonic() + result = sandbox.execute(ASYNC_CONCURRENT) + elapsed = time.monotonic() - start + assert result.stdout == "a,b,c\n", result.stderr + # Three 0.3s handlers awaited via gather should overlap, not serialize. + assert elapsed < 0.8, f"async handlers appear serialized ({elapsed:.2f}s)" + + +def test_async_handler_error_raises_in_guest(): + def boom(args): + raise RuntimeError("async host failure") + + with Sandbox(async_host_functions={"boom": boom}) as sandbox: + result = sandbox.execute(ASYNC_ERROR) + assert result.stdout == "caught\n", result.stderr + + +def test_unregistered_async_function_fails_future(): + with Sandbox() as sandbox: + result = sandbox.execute(ASYNC_ERROR) + assert result.stdout == "caught\n", result.stderr + + +def test_async_await_respects_execute_timeout(): + def hang(args): + time.sleep(60) + return None + + with Sandbox( + limits=ResourceLimits(timeout=1.0), async_host_functions={"hang": hang} + ) as sandbox: + with pytest.raises(PythonTimeoutError): + sandbox.execute( + "import asyncio\n" + "from boomslang_host.asyncio import async_call\n" + "async def main():\n" + " await async_call('hang', '{}')\n" + "asyncio.run(main())" + ) + assert sandbox.is_poisoned + + +def test_asyncio_sleep_works(): + with Sandbox() as sandbox: + result = sandbox.execute( + "import asyncio\n" + "import boomslang_host.asyncio\n" + "async def main():\n" + " await asyncio.sleep(0.05)\n" + " print('slept')\n" + "asyncio.run(main())" + ) + assert result.stdout == "slept\n", result.stderr diff --git a/boomslang-py/tests/test_bytecode.py b/boomslang-py/tests/test_bytecode.py new file mode 100644 index 0000000..935104c --- /dev/null +++ b/boomslang-py/tests/test_bytecode.py @@ -0,0 +1,42 @@ +import pytest + +from boomslang import PythonCompilationError, Sandbox + + +def test_compile_and_load_bytecode(sandbox): + bytecode = sandbox.compile("print('from bytecode')") + assert isinstance(bytecode, bytes) + assert len(bytecode) > 0 + result = sandbox.load_bytecode(bytecode) + assert result.stdout == "from bytecode\n", result.stderr + assert result.ok + + +def test_bytecode_reusable_across_sandboxes(sandbox): + bytecode = sandbox.compile("print(6 * 7)") + with Sandbox() as other: + result = other.load_bytecode(bytecode) + assert result.stdout == "42\n", result.stderr + + +def test_compile_syntax_error(sandbox): + with pytest.raises(PythonCompilationError): + sandbox.compile("def broken(:") + + +def test_execute_function(sandbox): + sandbox.execute("def add(a, b):\n print(a + b)") + result = sandbox.execute_function("add", "[2, 40]") + assert result.stdout == "42\n", result.stderr + assert result.ok + + +def test_execute_function_no_args(sandbox): + sandbox.execute("def hello():\n print('hi')") + result = sandbox.execute_function("hello") + assert result.stdout == "hi\n", result.stderr + + +def test_execute_function_missing(sandbox): + result = sandbox.execute_function("nope") + assert not result.ok diff --git a/boomslang-py/tests/test_host_functions.py b/boomslang-py/tests/test_host_functions.py index a98ac51..43ddc83 100644 --- a/boomslang-py/tests/test_host_functions.py +++ b/boomslang-py/tests/test_host_functions.py @@ -79,3 +79,56 @@ def test_custom_on_log(): with Sandbox(on_log=lambda level, message: seen.append((level, message))) as sandbox: sandbox.execute("from boomslang_host import log\nlog(3, 'warn msg')") assert seen == [(3, "warn msg")] + + +def test_large_host_function_result(): + # Larger than the guest bridge's fixed 1 MiB native buffer — fetched back + # through the chunked __result_pending__/__result_chunk__ protocol. + payload = "x" * (3 * 1024 * 1024) + with Sandbox(host_functions={"big": lambda args: payload}) as sandbox: + result = sandbox.execute( + "import json\n" + "from boomslang_host import call\n" + "data = json.loads(call('big', '{}'))\n" + "print(len(data), data[0], data[-1])" + ) + assert result.stdout == f"{len(payload)} x x\n", result.stderr + + +def test_large_async_host_function_result(): + payload = "y" * (2 * 1024 * 1024) + with Sandbox(async_host_functions={"big": lambda args: payload}) as sandbox: + result = sandbox.execute( + "import asyncio, json\n" + "from boomslang_host.asyncio import async_call\n" + "async def main():\n" + " return json.loads(await async_call('big', '{}'))\n" + "data = asyncio.run(main())\n" + "print(len(data), data[0])" + ) + assert result.stdout == f"{len(payload)} y\n", result.stderr + + +def test_error_after_large_result_still_raises(): + # A handler error must not be masked by a stale parked result. + payload = "z" * (2 * 1024 * 1024) + calls = {"n": 0} + + def flaky(args): + calls["n"] += 1 + if calls["n"] > 1: + raise RuntimeError("second call fails") + return payload + + with Sandbox(host_functions={"flaky": flaky}) as sandbox: + result = sandbox.execute( + "import json\n" + "from boomslang_host import call\n" + "first = json.loads(call('flaky', '{}'))\n" + "print(len(first))\n" + "try:\n" + " call('flaky', '{}')\n" + "except RuntimeError:\n" + " print('second-failed')" + ) + assert result.stdout == f"{len(payload)}\nsecond-failed\n", result.stderr diff --git a/boomslang-py/tests/test_stdin.py b/boomslang-py/tests/test_stdin.py new file mode 100644 index 0000000..6631816 --- /dev/null +++ b/boomslang-py/tests/test_stdin.py @@ -0,0 +1,35 @@ +def test_stdin_input(sandbox): + sandbox.set_stdin("Ada\n") + result = sandbox.execute("print('hello', input())") + assert result.stdout == "hello Ada\n", result.stderr + + +def test_stdin_read_all(sandbox): + sandbox.set_stdin(b"line1\nline2\n") + result = sandbox.execute("import sys\nprint(sys.stdin.read(), end='')") + assert result.stdout == "line1\nline2\n", result.stderr + + +def test_stdin_cleared_after_execute(sandbox): + sandbox.set_stdin("once\n") + first = sandbox.execute("print(input())") + assert first.stdout == "once\n", first.stderr + second = sandbox.execute( + "try:\n input()\nexcept EOFError:\n print('eof')" + ) + assert second.stdout == "eof\n", second.stderr + + +def test_clear_stdin(sandbox): + sandbox.set_stdin("never seen\n") + sandbox.clear_stdin() + result = sandbox.execute( + "try:\n input()\nexcept EOFError:\n print('eof')" + ) + assert result.stdout == "eof\n", result.stderr + + +def test_no_stdin_by_default(sandbox): + result = sandbox.execute("input()") + assert not result.ok + assert "EOFError" in result.stderr From 02310356c428ac8c8ad250cbeec07d0c507d9d83 Mon Sep 17 00:00:00 2001 From: Ze'ev Klapow Date: Wed, 10 Jun 2026 12:42:02 -0400 Subject: [PATCH 3/4] Disable Blazar build for boomslang-py --- boomslang-py/.blazar.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 boomslang-py/.blazar.yaml diff --git a/boomslang-py/.blazar.yaml b/boomslang-py/.blazar.yaml new file mode 100644 index 0000000..9c83d22 --- /dev/null +++ b/boomslang-py/.blazar.yaml @@ -0,0 +1,4 @@ +# The Python wheel is built and published by GitHub Actions (see +# .github/workflows/build.yml), not Blazar. Without this, Blazar auto-detects +# pyproject.toml as a Python module and fails to build it. +disabled: true From 2918173b1ed3028ff12fe22e83bdd1155121373a Mon Sep 17 00:00:00 2001 From: Ze'ev Klapow Date: Wed, 10 Jun 2026 13:05:17 -0400 Subject: [PATCH 4/4] Probe guest fs layout at runtime instead of hardcoding preopen order --- CLAUDE.md | 16 +- boomslang-py/README.md | 8 + boomslang-py/src/boomslang/_engine.py | 13 ++ boomslang-py/src/boomslang/_layout.py | 229 ++++++++++++++++++++++++++ boomslang-py/src/boomslang/sandbox.py | 122 +++++++++++--- 5 files changed, 355 insertions(+), 33 deletions(-) create mode 100644 boomslang-py/src/boomslang/_layout.py diff --git a/CLAUDE.md b/CLAUDE.md index a8ca3c3..17d408c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,12 +89,16 @@ just python-test # staged resources + venv + pytest just python-wheel # build dist/boomslang--py3-none-any.whl ``` -Key constraint: the guest libc's preopen table is baked in at Wizer time and -binds host preopens **positionally** — fd 3 = `/usr` (runtime, read-only), -fd 4 = `/lib`, fd 5 = `/work`, fd 6 = `/tmp`. The guest-path strings passed -to the WASI config are ignored by the guest, and arbitrary extra mount points -are unreachable. Any host (Java, Rust, or Python) must register mounts in -this order. +Key constraint: the guest libc's preopen table is baked into the Wizer +snapshot and binds host preopens **positionally** — the guest-path strings +passed to the WASI config are ignored, and mount points beyond the baked +table are unreachable. The baked table differs across runtime builds +(wasi-libc version dependent): current builds bake a single `/` entry (the +host provides one root dir shaped like the guest fs — same contract as the +Java host's rootPath), while older builds baked one entry per wizer-fs +subdir (`/usr`, `/lib`, `/work`, `/tmp`) in image-specific order. The Python +host probes the layout at runtime (`boomslang-py/src/boomslang/_layout.py`) +instead of assuming either. ## Project Structure diff --git a/boomslang-py/README.md b/boomslang-py/README.md index 49ca568..04bfe2b 100644 --- a/boomslang-py/README.md +++ b/boomslang-py/README.md @@ -66,6 +66,14 @@ preopen table is baked in at build time): additional mount points are not supported — share files through `/work`, and make extra pure-Python libraries importable by placing them in `lib_dir`. +The guest's mount table is frozen into the runtime image at build time, so +the sandbox probes it once per process and adapts. Depending on the image, +user-supplied `work_dir`/`lib_dir` are either mounted directly or emulated +by syncing files (hardlinks where possible) into and out of the guest around +each execution — semantics are the same either way: files present before an +execution are visible to the guest, and guest-created files appear on the +host after it. + ## Stdin ```python diff --git a/boomslang-py/src/boomslang/_engine.py b/boomslang-py/src/boomslang/_engine.py index 86f9d51..01d4707 100644 --- a/boomslang-py/src/boomslang/_engine.py +++ b/boomslang-py/src/boomslang/_engine.py @@ -3,6 +3,7 @@ import wasmtime from ._assets import wasm_path +from ._layout import Layout, probe_layout EPOCH_TICK_SECONDS = 0.01 @@ -35,6 +36,18 @@ def __init__(self) -> None: self.module = wasmtime.Module.from_file(self.engine, str(wasm_path())) self._ticker_lock = threading.Lock() self._ticker_started = False + self._layout: Layout | None = None + self._layout_lock = threading.Lock() + + def layout(self) -> Layout: + """The guest filesystem layout baked into this runtime image, + discovered once per process via a throwaway probe instance.""" + with self._layout_lock: + if self._layout is None: + self._layout = probe_layout( + self.engine, self.module, DISARMED_DEADLINE_TICKS + ) + return self._layout def ensure_ticker(self) -> None: with self._ticker_lock: diff --git a/boomslang-py/src/boomslang/_layout.py b/boomslang-py/src/boomslang/_layout.py new file mode 100644 index 0000000..2927779 --- /dev/null +++ b/boomslang-py/src/boomslang/_layout.py @@ -0,0 +1,229 @@ +"""Discovery of the guest filesystem layout baked into the runtime image. + +wasi-libc populates its preopen table during Wizer pre-initialization and the +table is frozen into the memory snapshot. At runtime the guest resolves paths +against those baked names, bound positionally (by fd order) to whatever the +host preopens — the guest-path strings the host registers are ignored, and +preopens beyond the baked table are unreachable. + +The baked table differs across runtime builds (it depends on the wasi-libc +version used): + +- single-root: one entry "/" — the host provides one root directory shaped + like the guest fs (usr/, lib/, work/, tmp/). This matches the Java host's + rootPath contract and is what current builds produce. +- positional: one entry per wizer-fs subdirectory (/usr, /lib, /work, /tmp) + in image-specific order — the host must register one directory per + position. Older builds produce this. + +Rather than hardcoding either, probe_layout() instantiates a throwaway +instance with marker directories and asks the guest what it sees. +""" + +import json +import logging +import os +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from wasmtime import FuncType, Linker, Store, ValType, WasiConfig + +from .errors import RuntimeAssetsError + +logger = logging.getLogger(__name__) + +_PROBE_NAMES = ("/", "/usr", "/lib", "/work", "/tmp") +_PROBE_SLOTS = 8 + +_PROBE_SCRIPT = """ +import os, json +mapping = {} +for name in (%r): + try: + entries = os.listdir(name) + except OSError: + continue + for entry in entries: + if entry.startswith('boomslang-probe-'): + mapping[name] = int(entry.rsplit('-', 1)[1]) +print(json.dumps(mapping)) +""" % (_PROBE_NAMES,) + + +@dataclass(frozen=True) +class Layout: + single_root: bool + # For positional layouts: guest name for each preopen position (None = + # position exists below a used one but maps to no known name; a filler + # directory must be registered to keep later positions aligned). + positions: tuple[str | None, ...] = () + + +def probe_layout(engine, module, disarmed_deadline_ticks: int) -> Layout: + with tempfile.TemporaryDirectory(prefix="boomslang-probe-") as tmp: + root = Path(tmp) + for i in range(_PROBE_SLOTS): + slot = root / f"p{i}" + slot.mkdir() + (slot / f"boomslang-probe-{i}").write_text("") + + store = Store(engine) + store.set_epoch_deadline(disarmed_deadline_ticks) + wasi = WasiConfig() + wasi.env = [("PYTHONHOME", "/usr/local")] + for i in range(_PROBE_SLOTS): + wasi.preopen_dir(str(root / f"p{i}"), f"/p{i}") + wasi.stdout_file = str(root / "wasi-stdout.log") + wasi.stderr_file = str(root / "wasi-stderr.log") + store.set_wasi(wasi) + + linker = Linker(engine) + linker.define_wasi() + i32 = ValType.i32() + linker.define_func( + "boomslang", "call", FuncType([i32] * 6, [i32]), lambda *args: -1 + ) + linker.define_func( + "boomslang", "log", FuncType([i32] * 3, []), lambda *args: None + ) + instance = linker.instantiate(store, module) + exports = instance.exports(store) + memory = exports["memory"] + + code = _PROBE_SCRIPT.encode("utf-8") + ptr = int(exports["alloc"](store, len(code))) & 0xFFFFFFFF + memory.write(store, code, ptr) + status = int(exports["execute"](store, ptr, len(code))) + out_len = int(exports["get_stdout_len"](store)) + if status != 0 or out_len <= 0: + raise RuntimeAssetsError( + f"filesystem layout probe failed (status {status})" + ) + buf = int(exports["alloc"](store, out_len)) & 0xFFFFFFFF + exports["get_stdout"](store, buf, out_len) + mapping: dict[str, int] = json.loads( + bytes(memory.read(store, buf, buf + out_len)).decode("utf-8") + ) + + if not mapping: + raise RuntimeAssetsError( + "filesystem layout probe found no reachable preopens; " + "the runtime image is incompatible with this host" + ) + if "/" in mapping: + return Layout(single_root=True) + + max_position = max(mapping.values()) + positions: list[str | None] = [None] * (max_position + 1) + for name, position in mapping.items(): + positions[position] = name + logger.debug("probed positional guest fs layout: %s", positions) + return Layout(single_root=False, positions=tuple(positions)) + + +# ---------------------------------------------------------------------- +# Single-root helpers + +def protected_usr_copy(usr_source: Path) -> Path: + """A shared read-only copy of the runtime's usr/ tree (files 0444, + directories untouched). Cached in the system temp dir keyed by the source + identity, so the 75 MB copy happens once per machine per runtime build, + not per process. Sandboxes hardlink into it; the read-only file mode is + what protects the shared content from guest writes.""" + source_stat = usr_source.stat() + key = f"{source_stat.st_dev}-{source_stat.st_ino}-{int(source_stat.st_mtime)}" + cache = Path(tempfile.gettempdir()) / f"boomslang-usr-{key}" + marker = cache / ".boomslang-complete" + if marker.is_file(): + return cache + + staging = Path( + tempfile.mkdtemp(prefix="boomslang-usr-staging-", dir=tempfile.gettempdir()) + ) + try: + target = staging / "usr" + shutil.copytree(usr_source, target) + for dirpath, _dirnames, filenames in os.walk(target): + for filename in filenames: + os.chmod(os.path.join(dirpath, filename), 0o444) + (target / ".boomslang-complete").write_text("") + try: + target.rename(cache) + except OSError: + if not marker.is_file(): + raise + return cache + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def link_tree(source: Path, dest: Path) -> None: + """Mirror source into dest using hardlinks (copy fallback), then make the + mirrored directories read-only so the guest cannot create files in them.""" + created_dirs = [] + for dirpath, dirnames, filenames in os.walk(source): + rel = os.path.relpath(dirpath, source) + target_dir = dest if rel == "." else dest / rel + target_dir.mkdir(exist_ok=True) + created_dirs.append(target_dir) + for filename in filenames: + if filename == ".boomslang-complete": + continue + source_file = os.path.join(dirpath, filename) + target_file = target_dir / filename + try: + os.link(source_file, target_file) + except OSError: + shutil.copy2(source_file, target_file) + os.chmod(target_file, 0o444) + for directory in reversed(created_dirs): + os.chmod(directory, 0o555) + + +def unprotect_tree(root: Path) -> None: + """Make directories under root writable again so cleanup can remove them.""" + for dirpath, _dirnames, _filenames in os.walk(root): + try: + os.chmod(dirpath, 0o755) + except OSError: + pass + + +def sync_dirs(source: Path, dest: Path) -> None: + """One-way, name-based sync of files from source into dest. Hardlinks + where possible so subsequent in-place writes propagate automatically; + replaces when source is newer; never deletes. Used to emulate a bind + mount of a user directory in single-root layouts.""" + if not source.is_dir(): + return + for dirpath, _dirnames, filenames in os.walk(source): + rel = os.path.relpath(dirpath, source) + target_dir = dest if rel == "." else dest / rel + target_dir.mkdir(parents=True, exist_ok=True) + for filename in filenames: + source_file = Path(dirpath) / filename + target_file = target_dir / filename + try: + src_stat = source_file.stat() + if target_file.exists(): + dst_stat = target_file.stat() + if ( + src_stat.st_dev == dst_stat.st_dev + and src_stat.st_ino == dst_stat.st_ino + ): + continue # already the same file + if src_stat.st_mtime_ns <= dst_stat.st_mtime_ns: + continue + target_file.unlink() + try: + os.link(source_file, target_file) + except OSError: + shutil.copy2(source_file, target_file) + except OSError: + logger.debug( + "failed to sync %s -> %s", source_file, target_file, exc_info=True + ) + + diff --git a/boomslang-py/src/boomslang/sandbox.py b/boomslang-py/src/boomslang/sandbox.py index 8b6567e..0609d6f 100644 --- a/boomslang-py/src/boomslang/sandbox.py +++ b/boomslang-py/src/boomslang/sandbox.py @@ -15,6 +15,7 @@ from ._assets import usr_host_dir from ._async import AsyncHostRegistry from ._engine import DISARMED_DEADLINE_TICKS, runtime +from ._layout import link_tree, protected_usr_copy, sync_dirs, unprotect_tree from ._trampolines import define_boomslang_imports from .errors import ( PythonCompilationError, @@ -176,22 +177,55 @@ def __init__( for name, fn in (async_host_functions or {}).items(): self._register_async(name, fn) - self._scratch = tempfile.TemporaryDirectory(prefix="boomslang-scratch-") - (Path(self._scratch.name) / "tmp").mkdir() - (Path(self._scratch.name) / "lib").mkdir() - if work_dir is None: - self._managed_work = tempfile.TemporaryDirectory(prefix="boomslang-work-") - self._work_dir = Path(self._managed_work.name) - else: - self._managed_work = None - self._work_dir = Path(work_dir) - self._work_dir.mkdir(parents=True, exist_ok=True) - if lib_dir is None: - self._lib_dir = Path(self._scratch.name) / "lib" + self._user_work = None if work_dir is None else Path(work_dir) + if self._user_work is not None: + self._user_work.mkdir(parents=True, exist_ok=True) + self._user_lib = None if lib_dir is None else Path(lib_dir) + if self._user_lib is not None and not self._user_lib.is_dir(): + raise ValueError(f"lib_dir is not a directory: {self._user_lib}") + + self._layout = runtime().layout() + self._scratch = tempfile.TemporaryDirectory( + prefix="boomslang-scratch-", ignore_cleanup_errors=True + ) + scratch = Path(self._scratch.name) + self._managed_work = None + self._sync_pairs: list[tuple[Path, Path]] = [] + self._root: Path | None = None + + if self._layout.single_root: + # The guest resolves every path through one preopen; build a root + # directory shaped like the guest fs. User-supplied work/lib dirs + # cannot be bind-mounted, so they are emulated by syncing files + # (hardlinks where possible) into/out of the backing dirs around + # each guest invocation. + root = scratch / "root" + root.mkdir() + for name in ("work", "lib", "tmp"): + (root / name).mkdir() + link_tree(protected_usr_copy(usr_host_dir()), root / "usr") + self._root = root + self._backing_work = root / "work" + self._backing_lib = root / "lib" + self._backing_tmp = root / "tmp" + if self._user_work is not None: + self._sync_pairs.append((self._user_work, self._backing_work)) + if self._user_lib is not None: + self._sync_pairs.append((self._user_lib, self._backing_lib)) else: - self._lib_dir = Path(lib_dir) - if not self._lib_dir.is_dir(): - raise ValueError(f"lib_dir is not a directory: {self._lib_dir}") + (scratch / "tmp").mkdir() + (scratch / "lib").mkdir() + self._backing_tmp = scratch / "tmp" + if self._user_work is None: + self._managed_work = tempfile.TemporaryDirectory( + prefix="boomslang-work-" + ) + self._backing_work = Path(self._managed_work.name) + else: + self._backing_work = self._user_work + self._backing_lib = ( + scratch / "lib" if self._user_lib is None else self._user_lib + ) self._instantiate() @@ -209,14 +243,33 @@ def _instantiate(self) -> None: wasi = WasiConfig() wasi.env = [("PYTHONHOME", "/usr/local"), ("PYTHONDONTWRITEBYTECODE", "1")] - # The guest libc's preopen table was baked in by Wizer and binds - # positionally: fd 3 = /usr, fd 4 = /lib, fd 5 = /work, fd 6 = /tmp. - # Registration order here is the contract; the guest_path strings are - # ignored by the guest. - wasi.preopen_dir(str(usr_host_dir()), "/usr", DirPerms.READ_ONLY, FilePerms.READ_ONLY) - wasi.preopen_dir(str(self._lib_dir), "/lib") - wasi.preopen_dir(str(self._work_dir), GUEST_WORK_PATH) - wasi.preopen_dir(str(Path(self._scratch.name) / "tmp"), "/tmp") + # The guest libc's preopen table is baked into the Wizer snapshot and + # binds host preopens positionally — the guest_path strings here are + # ignored by the guest. See _layout.py for the probed layout kinds. + if self._layout.single_root: + wasi.preopen_dir(str(self._root), "/") + else: + host_for_name = { + "/usr": (str(usr_host_dir()), True), + "/lib": (str(self._backing_lib), False), + GUEST_WORK_PATH: (str(self._backing_work), False), + "/tmp": (str(self._backing_tmp), False), + } + for position, name in enumerate(self._layout.positions): + if name in host_for_name: + host, read_only = host_for_name[name] + if read_only: + wasi.preopen_dir( + host, name, DirPerms.READ_ONLY, FilePerms.READ_ONLY + ) + else: + wasi.preopen_dir(host, name) + else: + # Unknown name at this position: register a filler so + # later positions stay aligned with the baked table. + filler = Path(self._scratch.name) / f"filler{position}" + filler.mkdir(exist_ok=True) + wasi.preopen_dir(str(filler), f"/filler{position}") # Guest stdout/stderr are captured by in-guest buffers; WASI streams # only carry low-level runtime diagnostics. wasi.stdout_file = str(Path(self._scratch.name) / ".wasi-stdout.log") @@ -263,10 +316,13 @@ def close(self) -> None: if self._closed: return self._closed = True + self._export_sync() self._fn = {} self._memory = None self._store = None self._async_registry.close() + if self._root is not None: + unprotect_tree(self._root / "usr") self._scratch.cleanup() if self._managed_work is not None: self._managed_work.cleanup() @@ -283,6 +339,7 @@ def __exit__(self, *exc_info) -> None: def execute(self, code: str) -> ExecutionResult: with self._lock: self._check_usable() + self._import_sync() start = time.perf_counter() exit_code = self._invoke_script("execute", code) return self._collect_result(exit_code, start) @@ -292,6 +349,7 @@ def execute_function(self, name: str, args_json: str = "") -> ExecutionResult: JSON array of positional arguments (e.g. "[2, 40]") or empty.""" with self._lock: self._check_usable() + self._import_sync() start = time.perf_counter() name_data = name.encode("utf-8") args_data = args_json.encode("utf-8") @@ -340,6 +398,7 @@ def load_bytecode(self, bytecode: bytes) -> ExecutionResult: """Execute bytecode previously produced by compile().""" with self._lock: self._check_usable() + self._import_sync() start = time.perf_counter() ptr = self._alloc(len(bytecode)) try: @@ -392,11 +451,20 @@ def _collect_result(self, exit_code: int, start: float) -> ExecutionResult: stdout = self._read_stream("stdout") stderr = self._read_stream("stderr") self._clear_stdin_locked() + self._export_sync() duration_ms = (time.perf_counter() - start) * 1000 return ExecutionResult( stdout=stdout, stderr=stderr, exit_code=exit_code, duration_ms=duration_ms ) + def _import_sync(self) -> None: + for user_dir, backing_dir in self._sync_pairs: + sync_dirs(user_dir, backing_dir) + + def _export_sync(self) -> None: + for user_dir, backing_dir in self._sync_pairs: + sync_dirs(backing_dir, user_dir) + def _invoke_script(self, fn_name: str, code: str) -> int: data = code.encode("utf-8") ptr = self._alloc(len(data)) @@ -539,13 +607,13 @@ def _read_parked_chunk(self, offset: int) -> str: @property def work_dir(self) -> Path: - """Host path mounted at /work inside the guest.""" - return self._work_dir + """Host path shared with the guest at /work.""" + return self._user_work if self._user_work is not None else self._backing_work @property def lib_dir(self) -> Path: - """Host path mounted at /lib inside the guest (on the guest's sys.path).""" - return self._lib_dir + """Host path shared with the guest at /lib (on the guest's sys.path).""" + return self._user_lib if self._user_lib is not None else self._backing_lib @property def guest_work_path(self) -> str: