From 01352f8dba1190a51287eb091c292fea2f514f98 Mon Sep 17 00:00:00 2001 From: chris hay Date: Mon, 27 Jul 2026 18:19:19 +0100 Subject: [PATCH 01/15] feat(execution): isolated code execution core + Seatbelt/local backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an IsolatedCodeRunner that runs untrusted/LLM-generated code behind a real boundary, with tool access brokered back to the trusted host over a single audited unix-socket channel — the safe counterpart to the in-process, trusted-only CodeSandbox. Core (backend-agnostic): - IsolationLimits, IsolatedResult, IsolationBackend protocol, GuestJob/Outcome - ToolBroker: host-side RPC server; owns the registry, enforces a per-run token, tool allowlist, and max-tool-calls ceiling; JSON-only wire (never pickle) - guest_bootstrap: dependency-free guest entrypoint that execs the code with async tool proxies and reports the result back over the socket - IsolatedCodeRunner: ties broker + backend together; refuses non-isolating backends unless allow_no_isolation=True Backends: - SubprocessBackend base (staging, rlimits, wall-clock kill, output caps) - LocalProcessBackend: no isolation, dev/testing/reference only - SeatbeltBackend: macOS sandbox-exec; denies inet network and filesystem writes outside the work/tmp dirs, denies reads of well-known secret dirs Tests cover the wire framing, limits validation, fail-closed behaviour, and the full core via the local backend; the Seatbelt suite runs on macOS and asserts the boundary actually blocks network and filesystem access. Signed-off-by: chris hay --- pyproject.toml | 4 + .../execution/isolation/__init__.py | 37 +++ .../execution/isolation/_wire.py | 58 +++++ .../execution/isolation/backend.py | 104 ++++++++ .../execution/isolation/backends/__init__.py | 10 + .../isolation/backends/_subprocess.py | 186 +++++++++++++++ .../execution/isolation/backends/local.py | 26 ++ .../execution/isolation/backends/seatbelt.py | 90 +++++++ .../execution/isolation/broker.py | 222 ++++++++++++++++++ .../execution/isolation/guest_bootstrap.py | 174 ++++++++++++++ .../execution/isolation/limits.py | 67 ++++++ .../execution/isolation/result.py | 42 ++++ .../execution/isolation/runner.py | 138 +++++++++++ tests/execution/isolation/test_runner.py | 219 +++++++++++++++++ 14 files changed, 1377 insertions(+) create mode 100644 src/chuk_tool_processor/execution/isolation/__init__.py create mode 100644 src/chuk_tool_processor/execution/isolation/_wire.py create mode 100644 src/chuk_tool_processor/execution/isolation/backend.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/__init__.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/_subprocess.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/local.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/seatbelt.py create mode 100644 src/chuk_tool_processor/execution/isolation/broker.py create mode 100644 src/chuk_tool_processor/execution/isolation/guest_bootstrap.py create mode 100644 src/chuk_tool_processor/execution/isolation/limits.py create mode 100644 src/chuk_tool_processor/execution/isolation/result.py create mode 100644 src/chuk_tool_processor/execution/isolation/runner.py create mode 100644 tests/execution/isolation/test_runner.py diff --git a/pyproject.toml b/pyproject.toml index 247d1b0..b7c054c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,10 @@ omit = [ [tool.mypy] python_version = "3.11" +# The isolation guest bootstrap is a standalone script copied into the guest at +# runtime (it imports a sibling _wire.py, not the package); don't type-check it +# as library code. +exclude = ["execution/isolation/guest_bootstrap\\.py$"] # Enable more warnings for better type safety warn_return_any = true warn_unused_configs = true diff --git a/src/chuk_tool_processor/execution/isolation/__init__.py b/src/chuk_tool_processor/execution/isolation/__init__.py new file mode 100644 index 0000000..27f3fc9 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/__init__.py @@ -0,0 +1,37 @@ +# chuk_tool_processor/execution/isolation/__init__.py +""" +Isolated code execution — run untrusted/LLM-generated code behind a real +OS/runtime boundary, with tool access brokered back to the trusted host. + +This is the safe counterpart to +:class:`~chuk_tool_processor.execution.code_sandbox.CodeSandbox`, which runs code +in-process with no isolation and is trusted-code-only. See ``docs/security.md``. +""" + +from chuk_tool_processor.execution.isolation.backend import ( + BackendUnavailableError, + GuestJob, + GuestOutcome, + IsolationBackend, + IsolationError, +) +from chuk_tool_processor.execution.isolation.backends import LocalProcessBackend, SeatbeltBackend +from chuk_tool_processor.execution.isolation.limits import IsolationLimits +from chuk_tool_processor.execution.isolation.result import IsolatedResult +from chuk_tool_processor.execution.isolation.runner import IsolatedCodeRunner + +__all__ = [ + # Runner + data types + "IsolatedCodeRunner", + "IsolationLimits", + "IsolatedResult", + # Backend protocol + errors + "IsolationBackend", + "IsolationError", + "BackendUnavailableError", + "GuestJob", + "GuestOutcome", + # Backends + "LocalProcessBackend", + "SeatbeltBackend", +] diff --git a/src/chuk_tool_processor/execution/isolation/_wire.py b/src/chuk_tool_processor/execution/isolation/_wire.py new file mode 100644 index 0000000..9245711 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/_wire.py @@ -0,0 +1,58 @@ +# chuk_tool_processor/execution/isolation/_wire.py +""" +Length-prefixed JSON message framing for the host<->guest tool-broker channel. + +This module MUST stay dependency-free (stdlib only, no chuk_tool_processor +imports): a copy of it is placed inside the isolated guest environment next to +the bootstrap, where the chuk_tool_processor package is not installed. + +Wire format: a 4-byte big-endian unsigned length prefix followed by that many +bytes of UTF-8 JSON. JSON (never pickle) is used deliberately — the guest is +untrusted, and unpickling guest-controlled bytes on the host would hand code +execution straight back across the boundary. +""" + +from __future__ import annotations + +import asyncio +import json +import struct +from typing import Any + +_LEN = struct.Struct(">I") + +# Hard cap on a single frame so a malicious guest cannot force a huge allocation. +MAX_FRAME_BYTES = 8 * 1024 * 1024 + + +def encode(obj: Any) -> bytes: + """Encode a message object to a length-prefixed JSON frame.""" + body = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + if len(body) > MAX_FRAME_BYTES: + raise ValueError(f"message too large: {len(body)} > {MAX_FRAME_BYTES}") + return _LEN.pack(len(body)) + body + + +async def send(writer: asyncio.StreamWriter, obj: Any) -> None: + """Send one framed message.""" + writer.write(encode(obj)) + await writer.drain() + + +async def recv(reader: asyncio.StreamReader) -> dict[str, Any]: + """ + Read one framed message. + + Raises: + EOFError: If the peer closed the connection cleanly at a frame boundary. + ValueError: If the frame is malformed or exceeds ``MAX_FRAME_BYTES``. + """ + header = await reader.readexactly(_LEN.size) + (length,) = _LEN.unpack(header) + if length > MAX_FRAME_BYTES: + raise ValueError(f"declared frame length {length} exceeds cap {MAX_FRAME_BYTES}") + body = await reader.readexactly(length) + obj = json.loads(body.decode("utf-8")) + if not isinstance(obj, dict): + raise ValueError("wire message must be a JSON object") + return obj diff --git a/src/chuk_tool_processor/execution/isolation/backend.py b/src/chuk_tool_processor/execution/isolation/backend.py new file mode 100644 index 0000000..6b62d95 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backend.py @@ -0,0 +1,104 @@ +# chuk_tool_processor/execution/isolation/backend.py +""" +Isolation backend protocol and the value types passed across it. + +A backend's single job: take a :class:`GuestJob` (untrusted code + limits + +the address of the host tool-broker socket), run it inside whatever isolation +mechanism the backend implements, and return a :class:`GuestOutcome`. Backends +never touch the tool registry — all tool access flows back to the host broker +over the broker channel. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from chuk_tool_processor.execution.isolation.limits import IsolationLimits + + +class IsolationError(Exception): + """Base class for isolation backend failures.""" + + +class BackendUnavailableError(IsolationError): + """Raised when a backend is selected but its runtime is not available.""" + + +@dataclass +class GuestJob: + """Everything the guest needs to run one snippet.""" + + code: str + token: str + limits: IsolationLimits + namespace: str | None = None + initial_vars: dict[str, Any] = field(default_factory=dict) + + def payload(self, *, socket_path: str) -> dict[str, Any]: + """ + Build the JSON job payload handed to the guest bootstrap. + + Args: + socket_path: Path to the broker unix socket *as seen from inside the + guest* (a backend that remaps paths, e.g. a container bind mount, + passes the guest-side path here). + """ + lim = self.limits + return { + "code": self.code, + "namespace": self.namespace, + "token": self.token, + "socket_path": socket_path, + "initial_vars": self.initial_vars, + "limits": { + "cpu_timeout": lim.cpu_timeout, + "memory_bytes": lim.memory_bytes, + "max_processes": lim.max_processes, + "max_output_bytes": lim.max_output_bytes, + }, + } + + +@dataclass +class GuestOutcome: + """Raw result of the guest process, before combining with broker state.""" + + exit_code: int | None + stdout: str = "" + stderr: str = "" + timed_out: bool = False + + +@runtime_checkable +class IsolationBackend(Protocol): + """ + A mechanism for running untrusted code away from the host process. + + Attributes: + name: Stable short identifier (e.g. "docker", "seatbelt"). + provides_isolation: True for backends that impose a real OS/runtime + boundary. False only for development/reference launchers that run + code with no containment — the runner refuses those unless the + caller explicitly opts in. + """ + + name: str + provides_isolation: bool + + def is_available(self) -> bool: + """Return True if this backend's runtime is present and usable now.""" + ... + + async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + """ + Run ``job`` inside the isolation boundary. + + Args: + job: The code, limits, and broker token to run. + host_socket_path: Path to the broker unix socket on the host. The + backend is responsible for making it reachable from inside the + guest (bind mount, shared namespace, etc.) and for telling the + guest the correct path via ``job.payload(socket_path=...)``. + """ + ... diff --git a/src/chuk_tool_processor/execution/isolation/backends/__init__.py b/src/chuk_tool_processor/execution/isolation/backends/__init__.py new file mode 100644 index 0000000..736afd6 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/__init__.py @@ -0,0 +1,10 @@ +# chuk_tool_processor/execution/isolation/backends/__init__.py +"""Isolation backends for running untrusted code away from the host process.""" + +from chuk_tool_processor.execution.isolation.backends.local import LocalProcessBackend +from chuk_tool_processor.execution.isolation.backends.seatbelt import SeatbeltBackend + +__all__ = [ + "LocalProcessBackend", + "SeatbeltBackend", +] diff --git a/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py new file mode 100644 index 0000000..160135a --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py @@ -0,0 +1,186 @@ +# chuk_tool_processor/execution/isolation/backends/_subprocess.py +""" +Shared launcher base for backends that run the guest as a local child process. + +Local (no isolation), Seatbelt (macOS ``sandbox-exec``), bubblewrap (Linux +namespaces), and Docker all follow the same recipe: stage the guest bootstrap +into a work dir, build `` python guest_bootstrap.py job.json``, +run it with a wall-clock kill, and capture output. They differ only in the +wrapper argv and (for containers) how host paths map into the guest — expressed +here as override hooks. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import shutil +import signal +import sys +import tempfile +from collections.abc import Callable +from dataclasses import dataclass + +from chuk_tool_processor.execution.isolation.backend import GuestJob, GuestOutcome +from chuk_tool_processor.logging import get_logger + +logger = get_logger("chuk_tool_processor.execution.isolation.subprocess") + +_ISO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_BOOTSTRAP_SRC = os.path.join(_ISO_DIR, "guest_bootstrap.py") +_WIRE_SRC = os.path.join(_ISO_DIR, "_wire.py") + +_POSIX = os.name == "posix" + + +@dataclass +class _LaunchCtx: + """Paths for one launch, both host-side and guest-visible.""" + + workdir: str # host path of the staging dir + host_socket_path: str # broker socket on the host + bootstrap_guest: str # bootstrap path as the guest sees it + job_guest: str # job.json path as the guest sees it + socket_guest: str # broker socket path as the guest sees it + + +class SubprocessBackend: + """Base class for local-child-process isolation backends.""" + + name = "subprocess" + provides_isolation = False + + # -- hooks subclasses override ----------------------------------------- # + + def is_available(self) -> bool: + return _POSIX + + def _python_exe(self) -> str: + """Interpreter that runs the guest bootstrap (guest-visible).""" + return sys.executable + + def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: # noqa: ARG002 - override hook + """Sandbox launcher prefix, e.g. ['sandbox-exec', '-p', profile].""" + return [] + + def _guest_ctx(self, workdir: str, host_socket_path: str) -> _LaunchCtx: + """Map host paths to guest-visible paths (identity for same-fs backends).""" + return _LaunchCtx( + workdir=workdir, + host_socket_path=host_socket_path, + bootstrap_guest=os.path.join(workdir, "guest_bootstrap.py"), + job_guest=os.path.join(workdir, "job.json"), + socket_guest=host_socket_path, + ) + + def _apply_rlimits_in_preexec(self) -> bool: + """Whether to set RLIMITs via preexec_fn (skip when the sandbox does it).""" + return _POSIX + + def _extra_env(self) -> dict[str, str]: + """Extra environment variables for the guest (merged over os.environ).""" + return {} + + # -- main flow --------------------------------------------------------- # + + async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + workdir = tempfile.mkdtemp(prefix="ctiso-") + os.chmod(workdir, 0o700) + try: + shutil.copy2(_BOOTSTRAP_SRC, os.path.join(workdir, "guest_bootstrap.py")) + shutil.copy2(_WIRE_SRC, os.path.join(workdir, "_wire.py")) + ctx = self._guest_ctx(workdir, host_socket_path) + + import json + + payload = job.payload(socket_path=ctx.socket_guest) + with open(os.path.join(workdir, "job.json"), "w", encoding="utf-8") as fh: + json.dump(payload, fh) + + argv = [ + *self._wrapper_argv(ctx, job), + self._python_exe(), + ctx.bootstrap_guest, + ctx.job_guest, + ] + logger.debug("[%s] launching guest: %s", self.name, " ".join(argv)) + return await self._spawn(argv, job) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + async def _spawn(self, argv: list[str], job: GuestJob) -> GuestOutcome: + preexec = self._make_preexec(job) if self._apply_rlimits_in_preexec() else None + extra_env = self._extra_env() + env = {**os.environ, **extra_env} if extra_env else None + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + preexec_fn=preexec, # noqa: PLW1509 - intentional per-child limits (POSIX) + start_new_session=_POSIX, + env=env, + ) + timed_out = False + stdout: bytes = b"" + stderr: bytes = b"" + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=job.limits.wall_timeout) + except TimeoutError: + timed_out = True + self._kill(proc) + with contextlib.suppress(Exception): + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5.0) + + cap = job.limits.max_output_bytes + return GuestOutcome( + exit_code=proc.returncode, + stdout=_decode(stdout, cap), + stderr=_decode(stderr, cap), + timed_out=timed_out, + ) + + def _kill(self, proc: asyncio.subprocess.Process) -> None: + with contextlib.suppress(ProcessLookupError, Exception): + if _POSIX: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + else: + proc.kill() + + def _make_preexec(self, job: GuestJob) -> Callable[[], None] | None: + if not _POSIX: + return None + import resource + + limits = job.limits + + def _preexec() -> None: # pragma: no cover - runs in the child + # NB: no os.setsid() here — start_new_session=True already makes the + # child a session leader; a second setsid would raise. + if limits.cpu_timeout: + _try_rlimit(resource.RLIMIT_CPU, int(limits.cpu_timeout) + 1) + if limits.memory_bytes and hasattr(resource, "RLIMIT_AS"): + _try_rlimit(resource.RLIMIT_AS, int(limits.memory_bytes)) + if limits.max_processes and hasattr(resource, "RLIMIT_NPROC"): + _try_rlimit(resource.RLIMIT_NPROC, int(limits.max_processes)) + + return _preexec + + +def _try_rlimit(res: int, value: int) -> None: # pragma: no cover - child process + import resource + + try: + _soft, hard = resource.getrlimit(res) + cap = value if hard == resource.RLIM_INFINITY else min(value, hard) + resource.setrlimit(res, (cap, hard)) + except (ValueError, OSError): + pass + + +def _decode(data: bytes | None, cap: int) -> str: + if not data: + return "" + if len(data) > cap: + data = data[:cap] + b"\n...[truncated]" + return data.decode("utf-8", errors="replace") diff --git a/src/chuk_tool_processor/execution/isolation/backends/local.py b/src/chuk_tool_processor/execution/isolation/backends/local.py new file mode 100644 index 0000000..938f9fe --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/local.py @@ -0,0 +1,26 @@ +# chuk_tool_processor/execution/isolation/backends/local.py +""" +Local subprocess backend — runs the guest in a plain child process. + +.. warning:: + This backend provides **no isolation boundary**. It runs the guest as an + ordinary child of the host with the same user and privileges; only the + resource limits (rlimits, timeout) and the broker's tool allowlist apply. + It exists for development, testing, and as the launcher base the real + isolation backends build on. ``IsolatedCodeRunner`` refuses to use it unless + the caller passes ``allow_no_isolation=True``. +""" + +from __future__ import annotations + +from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend + + +class LocalProcessBackend(SubprocessBackend): + """Non-isolating local child process (dev/testing only).""" + + name = "local" + provides_isolation = False + + def is_available(self) -> bool: + return True diff --git a/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py new file mode 100644 index 0000000..a8854ce --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py @@ -0,0 +1,90 @@ +# chuk_tool_processor/execution/isolation/backends/seatbelt.py +""" +macOS Seatbelt backend — runs the guest under ``sandbox-exec``. + +Seatbelt is Apple's kernel sandbox (the mechanism behind App Sandbox). We drive +it with a generated SBPL profile that denies everything by default and then +grants exactly what a short guest run needs: + + * network: inet is denied by default; only the unix broker socket is allowed + * filesystem writes: denied except the staging dir, the socket dir, and tmp + * filesystem reads: broadly allowed (CPython/dyld abort hard if a needed + library read is denied), but with well-known secret directories under + $HOME explicitly denied as hardening + +The reliably strong properties here are **no outbound network** and **no +filesystem writes** outside the sandboxed work/tmp dirs. Read confinement is +best-effort (a denylist, not an allowlist) because a strict read allowlist +breaks the interpreter. ``sandbox-exec`` is deprecated by Apple but still +functional and is the only built-in OS sandbox on macOS. +""" + +from __future__ import annotations + +import os +import shutil +import sys + +from chuk_tool_processor.execution.isolation.backend import GuestJob +from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx + +# Well-known secret locations under $HOME denied to the guest (hardening). +_SECRET_SUBPATHS = ( + ".ssh", + ".aws", + ".config/gcloud", + ".kube", + ".gnupg", + ".docker", + ".netrc", + ".git-credentials", + "Library/Keychains", + "Library/Application Support/com.apple.TCC", +) + + +def _q(path: str) -> str: + """Quote a path for an SBPL string literal.""" + return path.replace("\\", "\\\\").replace('"', '\\"') + + +class SeatbeltBackend(SubprocessBackend): + """OS-sandboxed guest via macOS ``sandbox-exec``.""" + + name = "seatbelt" + provides_isolation = True + + def is_available(self) -> bool: + return sys.platform == "darwin" and shutil.which("sandbox-exec") is not None + + def _extra_env(self) -> dict[str, str]: + # Avoid .pyc writes next to (read-only) stdlib modules. + return {"PYTHONDONTWRITEBYTECODE": "1"} + + def _profile(self, ctx: _LaunchCtx, job: GuestJob) -> str: # noqa: ARG002 - uniform hook signature + socket_dir = os.path.dirname(ctx.socket_guest) + home = os.path.realpath(os.path.expanduser("~")) + write_roots = [ctx.workdir, socket_dir, "/private/tmp", "/tmp"] + + lines = [ + "(version 1)", + "(deny default)", + "(allow process-fork)", + "(allow process-exec*)", + "(allow sysctl-read)", + "(allow mach-lookup)", + "(allow signal (target self))", + # Reads: broad (dyld/CPython abort if a needed lib read is denied)... + "(allow file-read*)", + # ...but deny well-known secret dirs under $HOME. + *[f'(deny file-read* (subpath "{_q(os.path.join(home, s))}"))' for s in _SECRET_SUBPATHS], + # Writes: only the staging dir, socket dir, tmp, and /dev/null. + *[f'(allow file-write* (subpath "{_q(os.path.realpath(p))}"))' for p in write_roots], + '(allow file-write* (literal "/dev/null"))', + # Network: only the unix broker socket. Inet stays denied by default. + "(allow network-outbound (remote unix-socket))", + ] + return "\n".join(lines) + "\n" + + def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: + return ["sandbox-exec", "-p", self._profile(ctx, job)] diff --git a/src/chuk_tool_processor/execution/isolation/broker.py b/src/chuk_tool_processor/execution/isolation/broker.py new file mode 100644 index 0000000..3aaf3ab --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/broker.py @@ -0,0 +1,222 @@ +# chuk_tool_processor/execution/isolation/broker.py +""" +Host-side tool broker for isolated code execution. + +The broker is the *only* channel the isolated guest has back into the host. It +listens on a unix-domain socket, authenticates the guest with a per-run token, +and exposes exactly two capabilities: + + list_tools(namespace) -> the names of tools the guest may call + call_tool(name, args) -> run a registered tool ON THE HOST and return JSON + +Tools run in the trusted host process (with their real credentials); only their +JSON-encoded results cross back to the guest. Everything the broker enforces — +the tool allowlist, the per-run call ceiling, the token, JSON-only framing — is +enforced here on the host, never in the guest. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import json +import os +import shutil +import tempfile +from typing import Any + +from chuk_tool_processor.execution.isolation import _wire +from chuk_tool_processor.execution.isolation.limits import IsolationLimits +from chuk_tool_processor.logging import get_logger +from chuk_tool_processor.registry.interface import ToolRegistryInterface + +logger = get_logger("chuk_tool_processor.execution.isolation.broker") + + +def _to_jsonable(obj: Any) -> Any: + """Best-effort conversion of a tool result into JSON-serialisable data.""" + try: + json.dumps(obj) + return obj + except (TypeError, ValueError): + pass + # Pydantic v2 / v1 + for attr in ("model_dump", "dict"): + method = getattr(obj, attr, None) + if callable(method): + with contextlib.suppress(Exception): + return _to_jsonable(method()) + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return _to_jsonable(dataclasses.asdict(obj)) + if isinstance(obj, dict): + return {str(k): _to_jsonable(v) for k, v in obj.items()} + if isinstance(obj, list | tuple): + return [_to_jsonable(v) for v in obj] + return repr(obj) + + +class ToolBroker: + """Serves tool calls to a single isolated guest over a unix socket.""" + + def __init__( + self, + registry: ToolRegistryInterface, + *, + token: str, + limits: IsolationLimits, + namespace: str | None = None, + allowed_tools: set[str] | None = None, + ) -> None: + self._registry = registry + self._token = token + self._limits = limits + self._namespace = namespace + self._allowed_tools = allowed_tools + + self._server: asyncio.AbstractServer | None = None + self._sock_dir: str | None = None + self._socket_path: str | None = None + + self._call_count = 0 + self._count_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + + self._result_future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + + # -- lifecycle --------------------------------------------------------- # + + async def start(self) -> str: + """Create the socket and start listening. Returns the host socket path.""" + # 0700 temp dir so only this user can reach the socket. + self._sock_dir = tempfile.mkdtemp(prefix="cticode-") + os.chmod(self._sock_dir, 0o700) + self._socket_path = os.path.join(self._sock_dir, "broker.sock") + self._server = await asyncio.start_unix_server(self._handle_client, path=self._socket_path) + os.chmod(self._socket_path, 0o600) + logger.debug("Tool broker listening at %s", self._socket_path) + return self._socket_path + + async def aclose(self) -> None: + """Stop the server and remove the socket directory.""" + if self._server is not None: + self._server.close() + with contextlib.suppress(Exception): + await self._server.wait_closed() + self._server = None + if self._sock_dir is not None: + shutil.rmtree(self._sock_dir, ignore_errors=True) + self._sock_dir = None + if not self._result_future.done(): + self._result_future.cancel() + + # -- accessors --------------------------------------------------------- # + + @property + def socket_path(self) -> str | None: + return self._socket_path + + @property + def tool_calls(self) -> int: + return self._call_count + + def result(self, default: Any = None) -> Any: + """The value the guest reported, or ``default`` if it never reported one.""" + if self._result_future.done() and not self._result_future.cancelled(): + return self._result_future.result() + return default + + def has_result(self) -> bool: + return self._result_future.done() and not self._result_future.cancelled() + + # -- connection handling ----------------------------------------------- # + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + hello = await _wire.recv(reader) + if hello.get("method") != "hello" or hello.get("token") != self._token: + logger.warning("Rejected guest connection: bad handshake") + await self._reply(writer, {"id": hello.get("id"), "ok": False, "error": "unauthorized"}) + return + await self._reply(writer, {"id": hello.get("id"), "ok": True}) + + while True: + try: + msg = await _wire.recv(reader) + except (EOFError, asyncio.IncompleteReadError): + break + # Each request handled concurrently so guest asyncio.gather works. + asyncio.create_task(self._dispatch(msg, writer)) + except Exception as exc: # noqa: BLE001 - broker must never crash the host + logger.debug("Broker connection error: %s", exc) + finally: + with contextlib.suppress(Exception): + writer.close() + + async def _dispatch(self, msg: dict[str, Any], writer: asyncio.StreamWriter) -> None: + method = msg.get("method") + msg_id = msg.get("id") + try: + if method == "list_tools": + await self._reply(writer, {"id": msg_id, "ok": True, "value": await self._list_tools()}) + elif method == "call_tool": + value = await self._call_tool(msg.get("params") or {}) + await self._reply(writer, {"id": msg_id, "ok": True, "value": value}) + elif method == "result": + if not self._result_future.done(): + self._result_future.set_result((msg.get("params") or {}).get("value")) + await self._reply(writer, {"id": msg_id, "ok": True}) + else: + await self._reply(writer, {"id": msg_id, "ok": False, "error": f"unknown method: {method}"}) + except _BrokerReject as exc: + await self._reply(writer, {"id": msg_id, "ok": False, "error": str(exc)}) + except Exception as exc: # noqa: BLE001 - surface as tool error, never crash host + await self._reply(writer, {"id": msg_id, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) + + async def _reply(self, writer: asyncio.StreamWriter, obj: dict[str, Any]) -> None: + async with self._write_lock: + with contextlib.suppress(Exception): + await _wire.send(writer, obj) + + # -- capabilities ------------------------------------------------------ # + + async def _list_tools(self) -> list[dict[str, str]]: + tools = await self._registry.list_tools(namespace=self._namespace) + out = [] + for info in tools: + name = getattr(info, "name", None) + ns = getattr(info, "namespace", None) or "default" + if not isinstance(name, str): + continue + if self._allowed_tools is not None and name not in self._allowed_tools: + continue + out.append({"name": name, "namespace": ns}) + return out + + async def _call_tool(self, params: dict[str, Any]) -> Any: + name = params.get("name") + namespace = params.get("namespace") or self._namespace or "default" + arguments = params.get("arguments") or {} + + if not isinstance(name, str): + raise _BrokerReject("call_tool requires a string 'name'") + if self._allowed_tools is not None and name not in self._allowed_tools: + raise _BrokerReject(f"tool not allowed: {name}") + if not isinstance(arguments, dict): + raise _BrokerReject("tool arguments must be an object") + + async with self._count_lock: + if self._call_count >= self._limits.max_tool_calls: + raise _BrokerReject(f"tool-call limit exceeded ({self._limits.max_tool_calls})") + self._call_count += 1 + + tool = await self._registry.get_tool(name, namespace) + if tool is None: + raise _BrokerReject(f"tool not found: {namespace}.{name}") + + result = await tool.execute(**arguments) + return _to_jsonable(result) + + +class _BrokerReject(Exception): + """Internal: a request rejected for policy reasons (reported to the guest).""" diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py new file mode 100644 index 0000000..6bde94f --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -0,0 +1,174 @@ +# chuk_tool_processor/execution/isolation/guest_bootstrap.py +""" +Guest-side entrypoint for isolated code execution. + +This script runs INSIDE the isolation boundary (subprocess / container / etc.). +It must stay dependency-free — stdlib plus a sibling copy of ``_wire.py`` only — +because the chuk_tool_processor package is not installed in the guest. + +It reads a JSON job file, applies best-effort in-process resource limits, opens +the tool-broker socket, executes the user code with async tool proxies bound in +its globals, and reports the return value back over the socket. Because the code +runs behind a real OS/runtime boundary, the guest deliberately does NOT restrict +builtins — containment is the boundary's job, not a curated namespace's. + +Usage: python guest_bootstrap.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys + +import _wire # sibling copy placed next to this file by the backend + + +def _apply_limits(limits: dict) -> None: + """Best-effort in-process resource limits (defense in depth; POSIX only).""" + try: + import resource + except ImportError: + return + + def _set(res: int, value) -> None: + try: + _soft, hard = resource.getrlimit(res) + cap = value if hard == resource.RLIM_INFINITY else min(value, hard) + resource.setrlimit(res, (cap, hard)) + except (ValueError, OSError): + pass + + cpu = limits.get("cpu_timeout") + if cpu: + _set(resource.RLIMIT_CPU, int(cpu) + 1) + mem = limits.get("memory_bytes") + if mem and hasattr(resource, "RLIMIT_AS"): + _set(resource.RLIMIT_AS, int(mem)) + procs = limits.get("max_processes") + if procs and hasattr(resource, "RLIMIT_NPROC"): + _set(resource.RLIMIT_NPROC, int(procs)) + + +def _jsonable(obj): + try: + json.dumps(obj) + return obj + except (TypeError, ValueError): + if isinstance(obj, dict): + return {str(k): _jsonable(v) for k, v in obj.items()} + if isinstance(obj, list | tuple): + return [_jsonable(v) for v in obj] + for attr in ("model_dump", "dict"): + method = getattr(obj, attr, None) + if callable(method): + try: + return _jsonable(method()) + except Exception: + pass + return repr(obj) + + +class _RpcClient: + """Multiplexes request/response frames over the broker socket by message id.""" + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + self._reader = reader + self._writer = writer + self._next_id = 0 + self._pending: dict[int, asyncio.Future] = {} + self._reader_task: asyncio.Task | None = None + + def start(self) -> None: + self._reader_task = asyncio.create_task(self._read_loop()) + + async def _read_loop(self) -> None: + try: + while True: + msg = await _wire.recv(self._reader) + fut = self._pending.pop(msg.get("id"), None) + if fut and not fut.done(): + fut.set_result(msg) + except (EOFError, asyncio.IncompleteReadError): + for fut in self._pending.values(): + if not fut.done(): + fut.set_exception(ConnectionError("broker closed the connection")) + + async def _roundtrip(self, frame: dict): + msg_id = self._next_id + self._next_id += 1 + frame["id"] = msg_id + fut = asyncio.get_running_loop().create_future() + self._pending[msg_id] = fut + await _wire.send(self._writer, frame) + reply = await fut + if not reply.get("ok"): + raise RuntimeError(reply.get("error") or "broker rejected request") + return reply.get("value") + + async def hello(self, token: str) -> None: + await self._roundtrip({"method": "hello", "token": token}) + + async def request(self, method: str, params: dict): + return await self._roundtrip({"method": method, "params": params}) + + +def _build_tool_proxy(client: _RpcClient, name: str, namespace: str): + async def _proxy(**kwargs): + return await client.request("call_tool", {"name": name, "namespace": namespace, "arguments": kwargs}) + + _proxy.__name__ = name + return _proxy + + +async def _run_user_code(code: str, exec_globals: dict): + """Execute user code, mirroring CodeSandbox's await/return wrapping.""" + needs_wrapping = "await " in code or "return " in code + local_scope: dict = {} + if needs_wrapping: + header = "async def __guest_main__():\n" if "await " in code else "def __guest_main__():\n" + wrapped = header + "".join(f" {line}\n" for line in code.split("\n")) + exec(compile(wrapped, "", "exec"), exec_globals, local_scope) + fn = local_scope["__guest_main__"] + return await fn() if "await " in code else fn() + exec(compile(code, "", "exec"), exec_globals, local_scope) + return local_scope.get("__result__") + + +async def _main(job: dict) -> int: + reader, writer = await asyncio.open_unix_connection(job["socket_path"]) + client = _RpcClient(reader, writer) + client.start() + await client.hello(job["token"]) + + tools = await client.request("list_tools", {}) + exec_globals: dict = dict(job.get("initial_vars") or {}) + for meta in tools: + exec_globals[meta["name"]] = _build_tool_proxy(client, meta["name"], meta["namespace"]) + + try: + value = await _run_user_code(job["code"], exec_globals) + await client.request("result", {"value": _jsonable(value)}) + writer.close() + return 0 + except Exception as exc: # report guest exceptions as structured output + print(f"{type(exc).__name__}: {exc}", file=sys.stderr) + raise + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print("usage: guest_bootstrap.py ", file=sys.stderr) + return 2 + with open(argv[1], encoding="utf-8") as fh: + job = json.load(fh) + _apply_limits(job.get("limits") or {}) + try: + return asyncio.run(_main(job)) + except Exception as exc: # noqa: BLE001 - top-level guest failure + print(f"guest failed: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/chuk_tool_processor/execution/isolation/limits.py b/src/chuk_tool_processor/execution/isolation/limits.py new file mode 100644 index 0000000..7f734f5 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/limits.py @@ -0,0 +1,67 @@ +# chuk_tool_processor/execution/isolation/limits.py +""" +Resource limits applied to isolated code execution. + +These are enforced by the isolation backend (OS rlimits, container flags, or a +WASM runtime's fuel/epoch limits) and by the host-side tool broker (tool-call +count, output size). A backend applies as many of these as its mechanism allows; +see each backend for which limits it can enforce. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Sensible defaults for running a short orchestration snippet. +DEFAULT_WALL_TIMEOUT = 30.0 +DEFAULT_CPU_TIMEOUT = 15.0 +DEFAULT_MEMORY_BYTES = 256 * 1024 * 1024 +DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 +DEFAULT_MAX_TOOL_CALLS = 100 +DEFAULT_MAX_PROCESSES = 64 + + +@dataclass(frozen=True) +class IsolationLimits: + """ + Resource ceilings for a single isolated execution. + + Attributes: + wall_timeout: Hard wall-clock ceiling in seconds. The backend kills the + guest when it is exceeded. Always enforced. + cpu_timeout: CPU-seconds ceiling (RLIMIT_CPU / container/runtime limit). + ``None`` disables it. Guards against busy loops that don't trip the + wall clock (e.g. while sleeping). + memory_bytes: Address-space / memory ceiling in bytes. ``None`` disables. + max_output_bytes: Maximum captured stdout/stderr bytes kept from the + guest. Output beyond this is truncated. + max_tool_calls: Maximum number of tool calls the guest may make through + the broker before further calls are rejected. + max_processes: Maximum number of processes/threads the guest may spawn + (RLIMIT_NPROC / container ``--pids-limit``). ``None`` disables. + allow_network: If ``False`` (default) the backend denies the guest all + network access except the single tool-broker channel. Set ``True`` + only when the isolated code legitimately needs outbound network. + """ + + wall_timeout: float = DEFAULT_WALL_TIMEOUT + cpu_timeout: float | None = DEFAULT_CPU_TIMEOUT + memory_bytes: int | None = DEFAULT_MEMORY_BYTES + max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES + max_tool_calls: int = DEFAULT_MAX_TOOL_CALLS + max_processes: int | None = DEFAULT_MAX_PROCESSES + allow_network: bool = False + + def __post_init__(self) -> None: + if self.wall_timeout <= 0: + raise ValueError("wall_timeout must be positive") + if self.cpu_timeout is not None and self.cpu_timeout <= 0: + raise ValueError("cpu_timeout must be positive or None") + if self.memory_bytes is not None and self.memory_bytes <= 0: + raise ValueError("memory_bytes must be positive or None") + if self.max_output_bytes <= 0: + raise ValueError("max_output_bytes must be positive") + if self.max_tool_calls < 0: + raise ValueError("max_tool_calls must be >= 0") + if self.max_processes is not None and self.max_processes <= 0: + raise ValueError("max_processes must be positive or None") diff --git a/src/chuk_tool_processor/execution/isolation/result.py b/src/chuk_tool_processor/execution/isolation/result.py new file mode 100644 index 0000000..c404328 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/result.py @@ -0,0 +1,42 @@ +# chuk_tool_processor/execution/isolation/result.py +"""Result of an isolated code execution.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class IsolatedResult: + """ + Outcome of running code through an :class:`IsolatedCodeRunner`. + + Attributes: + ok: True if the guest code ran to completion and returned a value. + value: The return value of the code (JSON round-tripped from the guest). + This is UNTRUSTED data produced by untrusted code — validate before + acting on it. + error: Error message if execution failed (guest exception, timeout, + limit exceeded, or backend failure). + error_type: Short classifier — e.g. "guest_exception", "timeout", + "limit_exceeded", "backend_error", "protocol_error". + stdout: Captured guest stdout (truncated to ``max_output_bytes``). + stderr: Captured guest stderr (truncated to ``max_output_bytes``). + tool_calls: Number of tool calls the guest made through the broker. + duration: Wall-clock seconds spent running the guest. + backend: Name of the isolation backend used. + timed_out: True if the guest was killed for exceeding the wall timeout. + """ + + ok: bool + value: Any = None + error: str | None = None + error_type: str | None = None + stdout: str = "" + stderr: str = "" + tool_calls: int = 0 + duration: float = 0.0 + backend: str = "" + timed_out: bool = False + meta: dict[str, Any] = field(default_factory=dict) diff --git a/src/chuk_tool_processor/execution/isolation/runner.py b/src/chuk_tool_processor/execution/isolation/runner.py new file mode 100644 index 0000000..0f1a654 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/runner.py @@ -0,0 +1,138 @@ +# chuk_tool_processor/execution/isolation/runner.py +""" +IsolatedCodeRunner — run untrusted/LLM-generated code behind a real boundary. + +Unlike :class:`~chuk_tool_processor.execution.code_sandbox.CodeSandbox` (which +runs code in-process with no isolation and is trusted-code-only), this runner +executes code inside an isolation backend (container, macOS Seatbelt, Linux +bubblewrap, WASM, ...). The code reaches registered tools only through the +host-side broker, over a single audited channel; everything else — network, +filesystem, host process — is denied by the backend. +""" + +from __future__ import annotations + +import secrets +import time +from typing import Any + +from chuk_tool_processor.execution.isolation.backend import ( + BackendUnavailableError, + GuestJob, + GuestOutcome, + IsolationBackend, + IsolationError, +) +from chuk_tool_processor.execution.isolation.broker import ToolBroker +from chuk_tool_processor.execution.isolation.limits import IsolationLimits +from chuk_tool_processor.execution.isolation.result import IsolatedResult +from chuk_tool_processor.logging import get_logger +from chuk_tool_processor.registry import get_default_registry +from chuk_tool_processor.registry.interface import ToolRegistryInterface + +logger = get_logger("chuk_tool_processor.execution.isolation.runner") + + +class IsolatedCodeRunner: + """Runs code inside an isolation backend with brokered tool access.""" + + def __init__( + self, + backend: IsolationBackend, + *, + registry: ToolRegistryInterface | None = None, + limits: IsolationLimits | None = None, + namespace: str | None = None, + allowed_tools: set[str] | None = None, + allow_no_isolation: bool = False, + ) -> None: + """ + Args: + backend: The isolation backend to run code in. + registry: Tool registry (default: the global registry). + limits: Resource ceilings (default: :class:`IsolationLimits`). + namespace: Restrict brokered tools to this namespace. + allowed_tools: If set, only these tool names may be called. + allow_no_isolation: Required to use a backend whose + ``provides_isolation`` is False (e.g. the local dev launcher). + Refuses otherwise, so a non-isolating backend cannot be used to + run untrusted code by accident. + """ + if not backend.provides_isolation and not allow_no_isolation: + raise IsolationError( + f"backend '{backend.name}' provides no isolation boundary; it must not be used " + "for untrusted code. Pass allow_no_isolation=True only for trusted code/testing." + ) + self.backend = backend + self.registry = registry + self.limits = limits or IsolationLimits() + self.namespace = namespace + self.allowed_tools = allowed_tools + + async def run( + self, + code: str, + *, + namespace: str | None = None, + initial_vars: dict[str, Any] | None = None, + ) -> IsolatedResult: + """Execute ``code`` in the backend and return an :class:`IsolatedResult`.""" + if not self.backend.is_available(): + raise BackendUnavailableError( + f"isolation backend '{self.backend.name}' is not available in this environment" + ) + + registry = self.registry or await get_default_registry() + ns = namespace if namespace is not None else self.namespace + token = secrets.token_hex(16) + + broker = ToolBroker( + registry, + token=token, + limits=self.limits, + namespace=ns, + allowed_tools=self.allowed_tools, + ) + socket_path = await broker.start() + try: + job = GuestJob( + code=code, + token=token, + limits=self.limits, + namespace=ns, + initial_vars=initial_vars or {}, + ) + started = time.monotonic() + outcome = await self.backend.run_guest(job, host_socket_path=socket_path) + duration = time.monotonic() - started + return self._assemble(outcome, broker, duration) + finally: + await broker.aclose() + + def _assemble(self, outcome: GuestOutcome, broker: ToolBroker, duration: float) -> IsolatedResult: + common: dict[str, Any] = { + "stdout": outcome.stdout, + "stderr": outcome.stderr, + "tool_calls": broker.tool_calls, + "duration": duration, + "backend": self.backend.name, + "timed_out": outcome.timed_out, + } + if broker.has_result(): + return IsolatedResult(ok=True, value=broker.result(), **common) + + if outcome.timed_out: + return IsolatedResult( + ok=False, + error=f"execution timed out after {self.limits.wall_timeout}s", + error_type="timeout", + **common, + ) + detail = outcome.stderr.strip() + if outcome.exit_code not in (0, None): + error = detail or f"guest exited with code {outcome.exit_code} without returning a result" + error_type = "guest_exception" + else: + error = detail or "guest ended without returning a result" + error_type = "protocol_error" + return IsolatedResult(ok=False, error=error, error_type=error_type, **common) diff --git a/tests/execution/isolation/test_runner.py b/tests/execution/isolation/test_runner.py new file mode 100644 index 0000000..f95fc45 --- /dev/null +++ b/tests/execution/isolation/test_runner.py @@ -0,0 +1,219 @@ +# tests/execution/isolation/test_runner.py +""" +Tests for isolated code execution (IsolatedCodeRunner + broker + backends). + +The Local backend (no isolation) exercises the whole core machinery on any OS. +The Seatbelt backend tests are skipped unless running on macOS with +``sandbox-exec`` available; where they run, they assert the boundary actually +blocks network and filesystem access. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +import pytest + +from chuk_tool_processor.execution.isolation import ( + IsolatedCodeRunner, + IsolationError, + IsolationLimits, + LocalProcessBackend, + SeatbeltBackend, + _wire, +) + + +# --------------------------------------------------------------------------- # +# Stub registry +# --------------------------------------------------------------------------- # +@dataclass +class _Info: + namespace: str + name: str + + +class _AddTool: + async def execute(self, a, b): + return {"sum": int(a) + int(b)} + + +class _EchoTool: + async def execute(self, text=""): + return {"text": text} + + +class StubRegistry: + async def list_tools(self, namespace=None): + return [_Info("math", "add"), _Info("math", "echo")] + + async def get_tool(self, name, namespace="default"): + return {"add": _AddTool(), "echo": _EchoTool()}.get(name) + + +@pytest.fixture +def registry(): + return StubRegistry() + + +def _local_runner(registry, **kw): + kw.setdefault("allow_no_isolation", True) + kw.setdefault("namespace", "math") + return IsolatedCodeRunner(LocalProcessBackend(), registry=registry, **kw) + + +ADD_LOOP = """ +total = 0 +for i in range(1, 6): + r = await add(a=str(total), b=str(i)) + total = r["sum"] +return total +""" + + +# --------------------------------------------------------------------------- # +# Wire framing +# --------------------------------------------------------------------------- # +class TestWire: + @pytest.mark.asyncio + async def test_roundtrip(self): + reader = asyncio.StreamReader() + reader.feed_data(_wire.encode({"method": "hello", "n": 5})) + reader.feed_eof() + msg = await _wire.recv(reader) + assert msg == {"method": "hello", "n": 5} + + @pytest.mark.asyncio + async def test_eof_raises(self): + reader = asyncio.StreamReader() + reader.feed_eof() + with pytest.raises((EOFError, asyncio.IncompleteReadError)): + await _wire.recv(reader) + + def test_oversize_rejected(self): + with pytest.raises(ValueError): + _wire.encode({"x": "a" * (_wire.MAX_FRAME_BYTES + 1)}) + + +# --------------------------------------------------------------------------- # +# Limits validation +# --------------------------------------------------------------------------- # +class TestLimits: + def test_defaults_ok(self): + lim = IsolationLimits() + assert lim.wall_timeout > 0 + assert lim.allow_network is False + + @pytest.mark.parametrize("kw", [{"wall_timeout": 0}, {"max_output_bytes": -1}, {"memory_bytes": 0}]) + def test_invalid(self, kw): + with pytest.raises(ValueError): + IsolationLimits(**kw) + + +# --------------------------------------------------------------------------- # +# Fail-closed +# --------------------------------------------------------------------------- # +class TestFailClosed: + def test_non_isolating_backend_refused(self): + with pytest.raises(IsolationError): + IsolatedCodeRunner(LocalProcessBackend()) + + def test_non_isolating_backend_allowed_with_optin(self): + # Should not raise. + IsolatedCodeRunner(LocalProcessBackend(), allow_no_isolation=True) + + +# --------------------------------------------------------------------------- # +# Core execution via the Local backend (works everywhere) +# --------------------------------------------------------------------------- # +class TestLocalExecution: + @pytest.mark.asyncio + async def test_add_loop(self, registry): + r = await _local_runner(registry).run(ADD_LOOP) + assert r.ok is True + assert r.value == 15 + assert r.tool_calls == 5 + + @pytest.mark.asyncio + async def test_return_value_and_initial_vars(self, registry): + r = await _local_runner(registry).run("return base * 2", initial_vars={"base": 21}) + assert r.ok and r.value == 42 + + @pytest.mark.asyncio + async def test_guest_exception_reported(self, registry): + r = await _local_runner(registry).run("return 1 / 0") + assert r.ok is False + assert r.error_type == "guest_exception" + assert "ZeroDivisionError" in (r.error or "") + + @pytest.mark.asyncio + async def test_max_tool_calls_enforced(self, registry): + runner = _local_runner(registry, limits=IsolationLimits(max_tool_calls=2)) + r = await runner.run(ADD_LOOP) + assert r.ok is False + assert r.tool_calls == 2 # third call rejected by the broker + + @pytest.mark.asyncio + async def test_allowed_tools_filter(self, registry): + runner = _local_runner(registry, allowed_tools={"echo"}) + # 'add' is not in the allowlist, so it is not bound in the guest globals. + r = await runner.run("return add(a='1', b='2')") + assert r.ok is False + + @pytest.mark.asyncio + async def test_timeout(self, registry): + runner = _local_runner(registry, limits=IsolationLimits(wall_timeout=1.0)) + r = await runner.run("while True:\n pass\nreturn 1") + assert r.ok is False + assert r.timed_out is True + assert r.error_type == "timeout" + + @pytest.mark.asyncio + async def test_output_truncated(self, registry): + runner = _local_runner(registry, limits=IsolationLimits(max_output_bytes=256)) + r = await runner.run("print('x' * 100000)\nreturn 1") + assert len(r.stdout.encode()) <= 256 + 64 + + +# --------------------------------------------------------------------------- # +# Seatbelt backend (macOS only) — asserts the boundary really blocks things +# --------------------------------------------------------------------------- # +_SEATBELT = SeatbeltBackend() + + +@pytest.mark.skipif(not _SEATBELT.is_available(), reason="requires macOS sandbox-exec") +class TestSeatbelt: + def _runner(self, registry, **kw): + kw.setdefault("namespace", "math") + kw.setdefault("limits", IsolationLimits(wall_timeout=10.0)) + return IsolatedCodeRunner(SeatbeltBackend(), registry=registry, **kw) + + @pytest.mark.asyncio + async def test_add_loop_and_tools_work(self, registry): + r = await self._runner(registry).run(ADD_LOOP) + assert r.ok is True and r.value == 15 and r.tool_calls == 5 + + @pytest.mark.asyncio + async def test_network_blocked(self, registry): + code = ( + "import socket\n" + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n" + "s.settimeout(3)\n" + "s.connect(('1.1.1.1', 53))\n" + "return 'NET_OK'" + ) + r = await self._runner(registry).run(code) + assert r.ok is False # inet connect denied by the sandbox + + @pytest.mark.asyncio + async def test_filesystem_write_blocked(self, registry): + code = "open('/etc/ctp_escape_test', 'w').write('x')\nreturn 'WROTE'" + r = await self._runner(registry).run(code) + assert r.ok is False # write outside the sandbox denied + + @pytest.mark.asyncio + async def test_timeout(self, registry): + runner = self._runner(registry, limits=IsolationLimits(wall_timeout=2.0)) + r = await runner.run("while True:\n pass\nreturn 1") + assert r.timed_out is True From ea12a76989fab497c6125e652478818810764ce7 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 11:26:45 +0100 Subject: [PATCH 02/15] feat(execution): add Docker and bubblewrap isolation backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the isolation core with two OS-level backends behind the same IsolationBackend protocol: - DockerBackend: one throwaway `docker run --rm` container per run — no network, read-only root, dropped caps, no-new-privileges, memory/pids limits; broker socket bind-mounted in. Force-removes the container by (token-derived) name on timeout. Works with any docker/podman-compatible CLI. - BubblewrapBackend: Linux `bwrap` namespace sandbox — read-only system view, private tmpfs, fresh /proc + /dev, net unshared unless allowed, only the broker socket bound in. Also make SeatbeltBackend's secret-read denylist configurable (deny_read_paths / add_deny_read_paths; DEFAULT_DENY_READ_PATHS as the base set) instead of a hard-coded list. Backends shell out to docker/bwrap/sandbox-exec (no Python deps). Tests cover argv construction (pure, run everywhere) and the configurable denylist; full container/namespace integration is gated on the runtime being present. Signed-off-by: chris hay --- .../execution/isolation/__init__.py | 9 +- .../execution/isolation/backends/__init__.py | 4 + .../isolation/backends/bubblewrap.py | 71 ++++++++ .../execution/isolation/backends/docker.py | 117 +++++++++++++ .../execution/isolation/backends/seatbelt.py | 54 ++++-- tests/execution/isolation/test_backends.py | 162 ++++++++++++++++++ 6 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/docker.py create mode 100644 tests/execution/isolation/test_backends.py diff --git a/src/chuk_tool_processor/execution/isolation/__init__.py b/src/chuk_tool_processor/execution/isolation/__init__.py index 27f3fc9..6b0ba3a 100644 --- a/src/chuk_tool_processor/execution/isolation/__init__.py +++ b/src/chuk_tool_processor/execution/isolation/__init__.py @@ -15,7 +15,12 @@ IsolationBackend, IsolationError, ) -from chuk_tool_processor.execution.isolation.backends import LocalProcessBackend, SeatbeltBackend +from chuk_tool_processor.execution.isolation.backends import ( + BubblewrapBackend, + DockerBackend, + LocalProcessBackend, + SeatbeltBackend, +) from chuk_tool_processor.execution.isolation.limits import IsolationLimits from chuk_tool_processor.execution.isolation.result import IsolatedResult from chuk_tool_processor.execution.isolation.runner import IsolatedCodeRunner @@ -34,4 +39,6 @@ # Backends "LocalProcessBackend", "SeatbeltBackend", + "DockerBackend", + "BubblewrapBackend", ] diff --git a/src/chuk_tool_processor/execution/isolation/backends/__init__.py b/src/chuk_tool_processor/execution/isolation/backends/__init__.py index 736afd6..0641d8b 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/__init__.py +++ b/src/chuk_tool_processor/execution/isolation/backends/__init__.py @@ -1,10 +1,14 @@ # chuk_tool_processor/execution/isolation/backends/__init__.py """Isolation backends for running untrusted code away from the host process.""" +from chuk_tool_processor.execution.isolation.backends.bubblewrap import BubblewrapBackend +from chuk_tool_processor.execution.isolation.backends.docker import DockerBackend from chuk_tool_processor.execution.isolation.backends.local import LocalProcessBackend from chuk_tool_processor.execution.isolation.backends.seatbelt import SeatbeltBackend __all__ = [ "LocalProcessBackend", "SeatbeltBackend", + "DockerBackend", + "BubblewrapBackend", ] diff --git a/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py b/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py new file mode 100644 index 0000000..c90e97c --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py @@ -0,0 +1,71 @@ +# chuk_tool_processor/execution/isolation/backends/bubblewrap.py +""" +bubblewrap backend — runs the guest in a Linux namespace sandbox via ``bwrap``. + +bubblewrap builds an unprivileged sandbox using user/mount/pid/net namespaces. +We give the guest a read-only view of the system + interpreter, a private +tmpfs, a fresh /proc and /dev, no network (unless explicitly allowed), and +bind-mount only the broker socket for host tool access. The staging dir and the +socket are bound at their original paths, so guest-visible paths match host +paths (the default identity mapping). + +Requires the ``bwrap`` binary (package ``bubblewrap``) on Linux. +""" + +from __future__ import annotations + +import shutil +import sys + +from chuk_tool_processor.execution.isolation.backend import GuestJob +from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx + +# System roots the interpreter needs, bind-mounted read-only when present. +_SYSTEM_ROOTS = ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc") + + +class BubblewrapBackend(SubprocessBackend): + """Linux namespace-isolated guest via ``bwrap``.""" + + name = "bubblewrap" + provides_isolation = True + + def is_available(self) -> bool: + return sys.platform.startswith("linux") and shutil.which("bwrap") is not None + + def _extra_env(self) -> dict[str, str]: + return {"PYTHONDONTWRITEBYTECODE": "1"} + + def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: + import os + + argv = ["bwrap", "--die-with-parent", "--new-session", "--unshare-user", "--unshare-pid", "--unshare-ipc"] + if not job.limits.allow_network: + argv += ["--unshare-net"] + + for root in _SYSTEM_ROOTS: + if os.path.exists(root): + argv += ["--ro-bind", root, root] + # Interpreter prefixes (venv/pyenv installs live outside /usr). + for prefix in {os.path.realpath(sys.base_prefix), os.path.realpath(sys.prefix)}: + argv += ["--ro-bind-try", prefix, prefix] + + argv += [ + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + # Staging dir read-only; broker socket dir writable for connect(). + "--ro-bind", + ctx.workdir, + ctx.workdir, + "--bind", + os.path.dirname(ctx.socket_guest), + os.path.dirname(ctx.socket_guest), + "--chdir", + "/", + "--", + ] + return argv diff --git a/src/chuk_tool_processor/execution/isolation/backends/docker.py b/src/chuk_tool_processor/execution/isolation/backends/docker.py new file mode 100644 index 0000000..6686dd5 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -0,0 +1,117 @@ +# chuk_tool_processor/execution/isolation/backends/docker.py +""" +Docker backend — runs the guest in a throwaway container. + +Each run launches one ``docker run --rm`` container with no network, a read-only +root, dropped capabilities, and memory/pids limits. The broker unix socket is +bind-mounted into the container so the guest can still call host tools; nothing +else crosses the boundary. Works anywhere a Docker/Podman-compatible ``docker`` +CLI reaches a daemon; the container only needs a stock ``python`` image because +all tool execution happens back on the host. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import shutil + +from chuk_tool_processor.execution.isolation.backend import GuestJob, GuestOutcome +from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx + +_GUEST_MOUNT = "/ctguest" +_SOCK_MOUNT = "/ctsock" + + +class DockerBackend(SubprocessBackend): + """Container-isolated guest via the ``docker`` CLI.""" + + name = "docker" + provides_isolation = True + + def __init__(self, image: str = "python:3.12-slim", *, cpus: float = 1.0, docker_bin: str = "docker") -> None: + self.image = image + self.cpus = cpus + self.docker_bin = docker_bin + + def is_available(self) -> bool: + return shutil.which(self.docker_bin) is not None + + # Container manages limits/interpreter; no host-side preexec or env. + def _python_exe(self) -> str: + return "python" + + def _apply_rlimits_in_preexec(self) -> bool: + return False + + def _guest_ctx(self, workdir: str, host_socket_path: str) -> _LaunchCtx: + socket_name = os.path.basename(host_socket_path) + return _LaunchCtx( + workdir=workdir, + host_socket_path=host_socket_path, + bootstrap_guest=f"{_GUEST_MOUNT}/guest_bootstrap.py", + job_guest=f"{_GUEST_MOUNT}/job.json", + socket_guest=f"{_SOCK_MOUNT}/{socket_name}", + ) + + def _container_name(self, job: GuestJob) -> str: + return f"ctiso-{job.token[:24]}" + + def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: + socket_dir = os.path.dirname(ctx.host_socket_path) + lim = job.limits + argv = [ + self.docker_bin, + "run", + "--rm", + "-i", + "--name", + self._container_name(job), + "--read-only", + "--tmpfs", + "/tmp", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--cpus", + str(self.cpus), + "-e", + "PYTHONDONTWRITEBYTECODE=1", + ] + if not lim.allow_network: + argv += ["--network", "none"] + if lim.memory_bytes: + argv += ["--memory", str(lim.memory_bytes), "--memory-swap", str(lim.memory_bytes)] + if lim.max_processes: + argv += ["--pids-limit", str(lim.max_processes)] + argv += [ + "-v", + f"{ctx.workdir}:{_GUEST_MOUNT}:ro", + "-v", + f"{socket_dir}:{_SOCK_MOUNT}", + self.image, + ] + return argv + + async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + # Killing the docker client on timeout does not stop the container, so + # force-remove by name in a finally (name is derived from the unique + # per-run token, making this concurrency-safe). + try: + return await super().run_guest(job, host_socket_path=host_socket_path) + finally: + await self._force_remove(self._container_name(job)) + + async def _force_remove(self, name: str) -> None: + with contextlib.suppress(Exception): + proc = await asyncio.create_subprocess_exec( + self.docker_bin, + "rm", + "-f", + name, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.wait_for(proc.wait(), timeout=10.0) diff --git a/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py index a8854ce..90c5945 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py +++ b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py @@ -24,22 +24,25 @@ import os import shutil import sys +from collections.abc import Sequence from chuk_tool_processor.execution.isolation.backend import GuestJob from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx -# Well-known secret locations under $HOME denied to the guest (hardening). -_SECRET_SUBPATHS = ( - ".ssh", - ".aws", - ".config/gcloud", - ".kube", - ".gnupg", - ".docker", - ".netrc", - ".git-credentials", - "Library/Keychains", - "Library/Application Support/com.apple.TCC", +# Default well-known secret locations denied to the guest (hardening). These are +# a starting set, not exhaustive — callers can replace or extend them; see +# SeatbeltBackend(deny_read_paths=..., add_deny_read_paths=...). +DEFAULT_DENY_READ_PATHS = ( + "~/.ssh", + "~/.aws", + "~/.config/gcloud", + "~/.kube", + "~/.gnupg", + "~/.docker", + "~/.netrc", + "~/.git-credentials", + "~/Library/Keychains", + "~/Library/Application Support/com.apple.TCC", ) @@ -48,12 +51,34 @@ def _q(path: str) -> str: return path.replace("\\", "\\\\").replace('"', '\\"') +def _resolve(paths: Sequence[str]) -> list[str]: + """Expand ~ and normalise each path to an absolute realpath.""" + return [os.path.realpath(os.path.expanduser(p)) for p in paths] + + class SeatbeltBackend(SubprocessBackend): """OS-sandboxed guest via macOS ``sandbox-exec``.""" name = "seatbelt" provides_isolation = True + def __init__( + self, + *, + deny_read_paths: Sequence[str] | None = None, + add_deny_read_paths: Sequence[str] = (), + ) -> None: + """ + Args: + deny_read_paths: Paths the guest may not read. Overrides the built-in + default set (:data:`DEFAULT_DENY_READ_PATHS`) entirely when given. + ``~`` is expanded; entries may be absolute or home-relative. + add_deny_read_paths: Extra paths to deny on top of whichever base set + is in effect (the default, or ``deny_read_paths``). + """ + base = DEFAULT_DENY_READ_PATHS if deny_read_paths is None else deny_read_paths + self.deny_read_paths = _resolve([*base, *add_deny_read_paths]) + def is_available(self) -> bool: return sys.platform == "darwin" and shutil.which("sandbox-exec") is not None @@ -63,7 +88,6 @@ def _extra_env(self) -> dict[str, str]: def _profile(self, ctx: _LaunchCtx, job: GuestJob) -> str: # noqa: ARG002 - uniform hook signature socket_dir = os.path.dirname(ctx.socket_guest) - home = os.path.realpath(os.path.expanduser("~")) write_roots = [ctx.workdir, socket_dir, "/private/tmp", "/tmp"] lines = [ @@ -76,8 +100,8 @@ def _profile(self, ctx: _LaunchCtx, job: GuestJob) -> str: # noqa: ARG002 - uni "(allow signal (target self))", # Reads: broad (dyld/CPython abort if a needed lib read is denied)... "(allow file-read*)", - # ...but deny well-known secret dirs under $HOME. - *[f'(deny file-read* (subpath "{_q(os.path.join(home, s))}"))' for s in _SECRET_SUBPATHS], + # ...but deny the configured secret paths. + *[f'(deny file-read* (subpath "{_q(p)}"))' for p in self.deny_read_paths], # Writes: only the staging dir, socket dir, tmp, and /dev/null. *[f'(allow file-write* (subpath "{_q(os.path.realpath(p))}"))' for p in write_roots], '(allow file-write* (literal "/dev/null"))', diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py new file mode 100644 index 0000000..cca5d17 --- /dev/null +++ b/tests/execution/isolation/test_backends.py @@ -0,0 +1,162 @@ +# tests/execution/isolation/test_backends.py +""" +Backend-specific tests. + +Argv/profile construction is pure and tested everywhere. Full integration for +Docker and bubblewrap is gated on the runtime actually being present and usable +(daemon up / correct platform), so these are skipped in environments without it. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +import pytest + +from chuk_tool_processor.execution.isolation import ( + BubblewrapBackend, + DockerBackend, + IsolatedCodeRunner, + IsolationLimits, + SeatbeltBackend, +) +from chuk_tool_processor.execution.isolation.backend import GuestJob +from tests.execution.isolation.test_runner import ADD_LOOP, StubRegistry + + +def _job(**limit_kw) -> GuestJob: + return GuestJob(code="x", token="tok" + "0" * 32, limits=IsolationLimits(**limit_kw)) + + +# --------------------------------------------------------------------------- # +# Docker argv construction +# --------------------------------------------------------------------------- # +class TestDockerArgv: + def test_guest_paths_are_container_mounts(self): + ctx = DockerBackend()._guest_ctx("/work", "/host/sock/broker.sock") + assert ctx.bootstrap_guest == "/ctguest/guest_bootstrap.py" + assert ctx.job_guest == "/ctguest/job.json" + assert ctx.socket_guest == "/ctsock/broker.sock" + + def test_wrapper_argv_hardening(self): + b = DockerBackend(image="python:3.12-slim") + ctx = b._guest_ctx("/work", "/host/sock/broker.sock") + argv = b._wrapper_argv(ctx, _job(memory_bytes=1_000_000, max_processes=7)) + assert argv[:3] == ["docker", "run", "--rm"] + assert "--network" in argv and "none" in argv + assert "--cap-drop" in argv and "ALL" in argv + assert "no-new-privileges" in argv + assert "--read-only" in argv + assert "1000000" in argv # --memory + assert "7" in argv # --pids-limit + assert "/work:/ctguest:ro" in argv + assert "/host/sock:/ctsock" in argv + assert argv[-1] == "python:3.12-slim" + assert f"ctiso-{_job().token[:24]}" in argv + + def test_allow_network_omits_none(self): + b = DockerBackend() + ctx = b._guest_ctx("/work", "/host/sock/broker.sock") + argv = b._wrapper_argv(ctx, _job(allow_network=True)) + # No "--network none" pairing when network is allowed. + assert not ("--network" in argv and "none" in argv) + + +# --------------------------------------------------------------------------- # +# Bubblewrap argv construction (pure; runs on any OS) +# --------------------------------------------------------------------------- # +class TestBubblewrapArgv: + def test_wrapper_argv(self): + b = BubblewrapBackend() + ctx = b._guest_ctx("/work", "/host/sock/broker.sock") + argv = b._wrapper_argv(ctx, _job()) + assert argv[0] == "bwrap" + assert "--die-with-parent" in argv + assert "--unshare-net" in argv + assert "--tmpfs" in argv + assert "--ro-bind" in argv + assert argv[-1] == "--" + + def test_allow_network_keeps_net(self): + b = BubblewrapBackend() + ctx = b._guest_ctx("/work", "/host/sock/broker.sock") + argv = b._wrapper_argv(ctx, _job(allow_network=True)) + assert "--unshare-net" not in argv + + +# --------------------------------------------------------------------------- # +# Seatbelt denylist configuration (pure profile construction; runs on any OS) +# --------------------------------------------------------------------------- # +class TestSeatbeltDenyReadPaths: + def _profile(self, backend: SeatbeltBackend) -> str: + ctx = backend._guest_ctx("/work", "/host/sock/broker.sock") + return backend._profile(ctx, _job()) + + def test_defaults_include_ssh_and_aws(self): + prof = self._profile(SeatbeltBackend()) + assert os.path.realpath(os.path.expanduser("~/.ssh")) in prof + assert os.path.realpath(os.path.expanduser("~/.aws")) in prof + + def test_add_extends_defaults(self): + prof = self._profile(SeatbeltBackend(add_deny_read_paths=["/data/secrets"])) + assert "/data/secrets" in prof + assert os.path.realpath(os.path.expanduser("~/.ssh")) in prof # defaults still present + + def test_deny_read_paths_overrides_defaults(self): + prof = self._profile(SeatbeltBackend(deny_read_paths=["~/only-this"])) + assert os.path.realpath(os.path.expanduser("~/only-this")) in prof + assert os.path.realpath(os.path.expanduser("~/.ssh")) not in prof # defaults replaced + + +# --------------------------------------------------------------------------- # +# Integration (gated) — Docker +# --------------------------------------------------------------------------- # +def _docker_up() -> bool: + if shutil.which("docker") is None: + return False + try: + return subprocess.run(["docker", "info"], capture_output=True, timeout=10).returncode == 0 + except Exception: + return False + + +@pytest.mark.skipif(not _docker_up(), reason="requires a running Docker daemon") +class TestDockerIntegration: + @pytest.mark.asyncio + async def test_add_loop(self): + runner = IsolatedCodeRunner( + DockerBackend(), + registry=StubRegistry(), + namespace="math", + limits=IsolationLimits(wall_timeout=60.0), + ) + r = await runner.run(ADD_LOOP) + assert r.ok is True and r.value == 15 and r.tool_calls == 5 + + @pytest.mark.asyncio + async def test_network_blocked(self): + runner = IsolatedCodeRunner( + DockerBackend(), + registry=StubRegistry(), + namespace="math", + limits=IsolationLimits(wall_timeout=60.0), + ) + code = "import socket\n" "socket.create_connection(('1.1.1.1', 53), timeout=3)\n" "return 'NET_OK'" + r = await runner.run(code) + assert r.ok is False + + +@pytest.mark.skipif(not BubblewrapBackend().is_available(), reason="requires Linux bwrap") +class TestBubblewrapIntegration: + @pytest.mark.asyncio + async def test_add_loop(self): + runner = IsolatedCodeRunner( + BubblewrapBackend(), + registry=StubRegistry(), + namespace="math", + limits=IsolationLimits(wall_timeout=30.0), + ) + r = await runner.run(ADD_LOOP) + assert r.ok is True and r.value == 15 From c487bf10021f3ec5292c7a5e3696adf30538fb0e Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 11:47:43 +0100 Subject: [PATCH 03/15] docs: document IsolatedCodeRunner and isolation backends Add docs/isolated_execution.md: architecture (broker + guest + JSON RPC + limits), backend comparison and selection, usage, the security/threat model, the configurable Seatbelt denylist, and how to write a custom backend. Point docs/security.md's "running untrusted code safely" section and the programmatic_execution.md CodeSandbox warning at IsolatedCodeRunner as the concrete answer for untrusted/LLM-generated code. Signed-off-by: chris hay --- docs/isolated_execution.md | 152 +++++++++++++++++++++++++++++++++ docs/programmatic_execution.md | 4 +- docs/security.md | 42 ++++++--- 3 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 docs/isolated_execution.md diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md new file mode 100644 index 0000000..daec202 --- /dev/null +++ b/docs/isolated_execution.md @@ -0,0 +1,152 @@ +# Isolated Code Execution + +`IsolatedCodeRunner` runs **untrusted or LLM-generated** Python behind a real +OS/runtime boundary, while still letting that code call your registered tools. +It is the safe counterpart to +[`CodeSandbox`](./programmatic_execution.md), which runs code in-process with no +isolation and is **trusted-code-only** (see [security.md](./security.md)). + +- **`CodeSandbox`** — in-process `exec()`, no boundary. Only for code you wrote. +- **`IsolatedCodeRunner`** — code runs inside a container / macOS Seatbelt / + Linux bubblewrap sandbox; tools are brokered back to the host over one audited + channel. For code you did **not** write. (A WASM backend is in development on a + separate branch.) + +## Why not just reuse the subprocess strategy? + +The existing `IsolatedStrategy` (`subprocess_strategy.py`) runs *registered tool +calls* in a `ProcessPoolExecutor` using **pickle**. That is fault isolation, not +security isolation: same OS user, no seccomp/rlimits/namespaces, and unpickling +data across the boundary is itself unsafe. It never executes the orchestration +code string. `IsolatedCodeRunner` is a separate mechanism built for untrusted +*code*. + +## Architecture + +The hard, backend-independent part is the **tool bridge**: untrusted code must +reach host tools (which hold real credentials) without any other host access. + +``` +host process (trusted) isolated guest (untrusted) +┌───────────────────────────┐ ┌──────────────────────────┐ +│ IsolatedCodeRunner │ │ guest_bootstrap.py │ +│ ├─ owns the registry │ │ ├─ exec(user code) │ +│ ├─ ToolBroker (RPC srv) │◄──JSON RPC───┤ └─ async tool proxies ──┼─┐ +│ │ • list_tools() │ 1 unix fd │ call_tool(name,kw) │ │ +│ │ • call_tool() ───────┼─ runs REAL └──────────────────────────┘ │ +│ │ (token+allowlist) │ tool here ▲ │ +│ └─ IsolationBackend ─────┼─ spawns guest ──────┘ │ +└───────────────────────────┘ with limits; nothing else crosses ◄─────┘ +``` + +Invariants: + +- **The broker channel is the only hole.** Network, filesystem, and host + processes are denied by the backend; the guest can only reach the socket. +- **JSON on the wire, never pickle.** The guest is untrusted; unpickling + guest-controlled bytes on the host would defeat the whole exercise. +- **Policy is enforced host-side.** The per-run token, the tool allowlist, and + the `max_tool_calls` ceiling live in `ToolBroker`, not in the guest. +- **The return value is untrusted data.** `IsolatedResult.value` is JSON + produced by untrusted code — validate before acting on it. + +## Quick start + +```python +from chuk_tool_processor.execution.isolation import ( + IsolatedCodeRunner, DockerBackend, IsolationLimits, +) + +runner = IsolatedCodeRunner( + DockerBackend(), # or SeatbeltBackend(), BubblewrapBackend() + namespace="math", # tools the guest may call + limits=IsolationLimits(wall_timeout=30.0, allow_network=False), +) + +result = await runner.run(""" +total = 0 +for i in range(1, 6): + r = await add(a=str(total), b=str(i)) # 'add' is a brokered host tool + total = r["sum"] +return total +""") + +print(result.ok, result.value, result.tool_calls) # True 15 5 +``` + +Pick the backend that matches where you deploy; the runner refuses a +non-isolating backend unless you pass `allow_no_isolation=True`. + +## Backends + +| Backend | Isolation | Platform | Needs | Notes | +|---|---|---|---|---| +| `DockerBackend` | Strong | any w/ Docker/Podman | `docker` CLI + daemon | throwaway container, `--network none`, read-only root, dropped caps | +| `SeatbeltBackend` | Strong* | macOS | `sandbox-exec` (built in) | no inet, no fs-writes outside work/tmp, secret dirs unreadable | +| `BubblewrapBackend` | Strong | Linux | `bwrap` binary | user/mount/pid/net namespaces | +| `LocalProcessBackend` | **None** | any | — | dev/testing only; runner refuses it without `allow_no_isolation=True` | + +\* Seatbelt reliably blocks network and filesystem *writes*; read confinement is +best-effort (broad reads with known secret dirs denied) because a strict read +allowlist aborts CPython. The denied secret paths are configurable — +`SeatbeltBackend(deny_read_paths=..., add_deny_read_paths=...)` — defaulting to +`DEFAULT_DENY_READ_PATHS` (`~/.ssh`, `~/.aws`, cloud creds, keychains, …). +`sandbox-exec` is deprecated by Apple but functional. + +A **WASM backend** (wasmtime/WASI — the strongest boundary by construction) is in +development on a separate branch; it is not part of this release. + +Install notes: the Docker, Seatbelt, and bubblewrap backends need no Python +dependencies (they shell out to the respective binary). + +## Resource limits + +`IsolationLimits` (all enforced as far as the backend allows): + +| Field | Default | Meaning | +|---|---|---| +| `wall_timeout` | 30s | hard wall-clock kill (always enforced) | +| `cpu_timeout` | 15s | CPU-seconds ceiling (RLIMIT_CPU / container) | +| `memory_bytes` | 256 MiB | memory ceiling (RLIMIT_AS / `--memory`) | +| `max_output_bytes` | 64 KiB | captured stdout/stderr cap | +| `max_tool_calls` | 100 | broker rejects calls beyond this | +| `max_processes` | 64 | RLIMIT_NPROC / `--pids-limit` | +| `allow_network` | `False` | deny all guest network except the broker channel | + +## Security model + +What the boundary is expected to stop, and where enforced: + +- **Arbitrary host code execution / sandbox escape** → the backend (container, + namespace, or Seatbelt). Even a full `CodeSandbox`-style `__subclasses__()` + escape only reaches the *guest's* interpreter, which has no host access beyond + the broker socket. +- **Reaching tools you didn't expose** → `ToolBroker` allowlist + namespace. +- **Tool-call flooding** → `max_tool_calls`. +- **Network exfiltration** → `allow_network=False` (default). +- **Reading host secrets / writing host files** → backend filesystem policy. +- **Runaway CPU/memory/fork bombs** → limits (`wall_timeout`, `cpu_timeout`, + `memory_bytes`, `max_processes`). + +Residual risks: the broker still runs *your* tools with their real privileges on +the guest's behalf — expose only tools that are safe to call with +attacker-chosen arguments. Seatbelt read-confinement is best-effort. The guest's +return value is untrusted. + +## Writing a custom backend + +Implement the `IsolationBackend` protocol: + +```python +class MyBackend: + name = "mybackend" + provides_isolation = True # False => runner requires allow_no_isolation + + def is_available(self) -> bool: ... + async def run_guest(self, job, *, host_socket_path) -> GuestOutcome: ... +``` + +Most OS-level backends should subclass `SubprocessBackend` and override just +`_wrapper_argv()` (the sandbox launcher prefix) and, if paths are remapped, +`_guest_ctx()` — see `DockerBackend` for the remapping pattern. +``` diff --git a/docs/programmatic_execution.md b/docs/programmatic_execution.md index 0db688f..0ff7b93 100644 --- a/docs/programmatic_execution.md +++ b/docs/programmatic_execution.md @@ -66,7 +66,9 @@ The tool-processor includes a **built-in in-process code executor** (`CodeSandbo > code. For this reason execution is **disabled by default** and you must pass > `allow_unsafe_execution=True`. Only do so for code you fully trust (code you > authored). **Do not pass untrusted or LLM-generated code to it** expecting -> containment — for that you need real OS/process-level isolation. See +> containment — for that use +> [`IsolatedCodeRunner`](./isolated_execution.md), which runs code behind a real +> OS/runtime boundary and brokers tool access back to the host. See also the > [Security Guide](./security.md). **Characteristics**: diff --git a/docs/security.md b/docs/security.md index 6830e3f..c795113 100644 --- a/docs/security.md +++ b/docs/security.md @@ -63,20 +63,34 @@ NOT a security boundary ... ## Running untrusted code safely -If executing untrusted or LLM-generated code is a genuine requirement, put a real -boundary between that code and your process. Standard options, roughly in order -of increasing isolation: - -1. **Separate subprocess as a locked-down user**, with `seccomp`, resource - limits (`RLIMIT_*`), no network namespace, and a read-only / ephemeral - filesystem. -2. **Container / microVM isolation** — gVisor, Firecracker, or an equivalent - sandbox runtime, one throwaway instance per execution. -3. **WebAssembly interpreter** (e.g. a WASM-compiled Python) so the guest code - cannot reach host syscalls at all. - -In every case, expose tools to the guest through a narrow, audited RPC surface -rather than by handing it live Python objects. +If executing untrusted or LLM-generated code is a genuine requirement, use +[`IsolatedCodeRunner`](./isolated_execution.md) instead of `CodeSandbox`. It runs +the code behind a real OS/runtime boundary and brokers tool access back to the +host over a single audited channel (JSON, never pickle) — exactly the "narrow, +audited RPC surface" pattern below, already built: + +```python +from chuk_tool_processor.execution.isolation import IsolatedCodeRunner, DockerBackend + +runner = IsolatedCodeRunner(DockerBackend(), namespace="math") +result = await runner.run(untrusted_code) # no network, no host fs, tools brokered +``` + +Backends, in roughly increasing isolation strength: + +1. **`SeatbeltBackend`** (macOS) / **`BubblewrapBackend`** (Linux) — OS sandbox: + resource limits, no network, filesystem confined, only the broker channel open. +2. **`DockerBackend`** — one throwaway container per run (`--network none`, + read-only root, dropped caps, memory/pids limits); works anywhere Docker/Podman + runs. Combine with a gVisor/Firecracker runtime for microVM-grade isolation. + +A WASM backend (guest cannot reach host syscalls at all) is in development on a +separate branch. + +In every case tools are exposed through the host-side broker (allowlist + call +ceiling + per-run token), never by handing the guest live Python objects. See +[isolated_execution.md](./isolated_execution.md) for the full model, limits, and +how to add a custom backend. ## Reporting security issues From a0a51ca23982f65375730bccada70e20b6cfa420 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 12:17:53 +0100 Subject: [PATCH 04/15] docs: stop presenting IsolatedStrategy as a security boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README, CORE_CONCEPTS, and ADVANCED_TOPICS described the subprocess IsolatedStrategy as the way to run "untrusted"/"LLM-generated code" "safely" and called it a "security boundary". It is crash/fault isolation only — same OS user, no seccomp/namespaces, results cross via pickle — and it never executes an orchestration code string. Reframe it as crash isolation for tool dispatch and route untrusted/LLM *code* to IsolatedCodeRunner instead. Add docs/isolated_execution.md and docs/security.md to the README docs index. Signed-off-by: chris hay --- README.md | 8 +++++--- docs/ADVANCED_TOPICS.md | 38 +++++++++++++++++++++++++------------- docs/CORE_CONCEPTS.md | 26 +++++++++++++++----------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9fe45e9..410834d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Parsers (XML / OpenAI / JSON) Execution Strategy ┌──────────────────────┐ │ • InProcess │ ← Fast, trusted - │ • Isolated/Subprocess│ ← Safe, untrusted + │ • Isolated/Subprocess│ ← Crash isolation │ • Remote via MCP │ ← Distributed └──────────────────────┘ ``` @@ -160,7 +160,7 @@ results = await processor.process(json_output) | **Pattern Bulkheads** | Glob patterns like `"db.*": 3` for grouped concurrency limits | | **Scoped Registries** | Isolated registries for multi-tenant apps and testing | | **ExecutionContext** | Request-scoped metadata propagation (user, tenant, tracing, deadlines) | -| **Isolated Strategy** | Subprocess execution for untrusted code (zero crash blast radius) | +| **Isolated Strategy** | Subprocess tool execution for crash/fault isolation (zero crash blast radius). Not a security boundary — for untrusted/LLM *code*, see [`IsolatedCodeRunner`](docs/isolated_execution.md) | | **Redis Registry** | Distributed tool registry for multi-process/multi-machine deployments | ### Advanced Scheduling @@ -573,6 +573,8 @@ See [ERRORS.md](docs/ERRORS.md) for complete error taxonomy. | [**GUARDS.md**](docs/GUARDS.md) | Runtime guards for safety, validation, and resource management | | [**MCP_INTEGRATION.md**](docs/MCP_INTEGRATION.md) | HTTP Streamable, STDIO, SSE, OAuth, Middleware Stack | | [**ADVANCED_TOPICS.md**](docs/ADVANCED_TOPICS.md) | Deferred loading, code sandbox, isolated strategy, testing | +| [**isolated_execution.md**](docs/isolated_execution.md) | Running untrusted/LLM code behind a real boundary (`IsolatedCodeRunner`, backends) | +| [**security.md**](docs/security.md) | Security model: `CodeSandbox` vs real isolation, running untrusted code safely | | [**CONFIGURATION.md**](docs/CONFIGURATION.md) | All config options and environment variables | | [**OBSERVABILITY.md**](docs/OBSERVABILITY.md) | OpenTelemetry, Prometheus, metrics reference | | [**ERRORS.md**](docs/ERRORS.md) | Error codes and handling patterns | @@ -663,7 +665,7 @@ pip install chuk-tool-processor[all] **Use CHUK Tool Processor when:** - Your LLM calls tools or APIs - You need retries, timeouts, caching, or rate limits -- You need to run untrusted tools safely +- You need crash isolation for flaky tools, or a real boundary for untrusted/LLM code ([`IsolatedCodeRunner`](docs/isolated_execution.md)) - Your tools are local or remote (MCP) - You need multi-tenant isolation - You want production-grade observability diff --git a/docs/ADVANCED_TOPICS.md b/docs/ADVANCED_TOPICS.md index b0fd96b..dac6222 100644 --- a/docs/ADVANCED_TOPICS.md +++ b/docs/ADVANCED_TOPICS.md @@ -184,7 +184,16 @@ See `examples/code_sandbox_demo.py` and `examples/advanced_tool_use_math_server. ## Using Isolated Strategy -Use `IsolatedStrategy` when running untrusted, third-party, or potentially unsafe code that shouldn't share the same process as your main app. +Use `IsolatedStrategy` for **crash/fault isolation** of tool execution — it runs each registered **tool call** in a separate worker process so a hanging or crashing tool can't take down your app. + +> [!WARNING] +> `IsolatedStrategy` is **not a security boundary**. Workers run as the same OS +> user with no seccomp/namespace/rlimit confinement, and tool arguments/results +> cross the boundary via **pickle**. It also only governs how *registered tool +> calls* are dispatched — it never executes an orchestration **code string**. For +> running untrusted or LLM-generated *code*, use +> [`IsolatedCodeRunner`](./isolated_execution.md) (real OS/container/WASM +> isolation with brokered tool access), not this strategy and not `CodeSandbox`. ```python import asyncio @@ -206,25 +215,28 @@ async def main(): asyncio.run(main()) ``` -### Security & Isolation — Threat Model +### What IsolatedStrategy actually protects against | Aspect | Protection | |--------|------------| -| **Process Isolation** | Untrusted code runs in subprocesses | -| **Crash Blast Radius** | Zero — faults don't bring down your app | -| **Resource Limits** | Use containers with `--cpus`, `--memory` | -| **Network Isolation** | Egress filtering via container network policy | +| **Crash Blast Radius** | Zero — a crashing/hanging tool doesn't bring down your app | +| **Process separation** | Each tool call runs in a separate worker process (fault, not security, isolation) | +| **Timeouts** | Per-call deadlines terminate stuck workers | | **Secrets** | Never injected by default — pass explicitly | +This is **fault isolation, not a security sandbox** — see the warning above. For a +real security boundary around untrusted code, use +[`IsolatedCodeRunner`](./isolated_execution.md), optionally with a container/gVisor +runtime for `--cpus`/`--memory`/egress limits. + ### When to Use Each Strategy -| Scenario | Strategy | -|----------|----------| -| Trusted internal tools | InProcessStrategy | -| External/user-provided code | IsolatedStrategy | -| LLM-generated code execution | IsolatedStrategy | -| Performance-critical path | InProcessStrategy | -| Tools that might crash | IsolatedStrategy | +| Scenario | Use | +|----------|-----| +| Trusted internal tools | `InProcessStrategy` | +| Performance-critical path | `InProcessStrategy` | +| Tools that might crash or hang | `IsolatedStrategy` (crash isolation) | +| Untrusted / third-party / LLM-generated **code** | [`IsolatedCodeRunner`](./isolated_execution.md) (real isolation) — *not* `IsolatedStrategy`, *not* `CodeSandbox` | --- diff --git a/docs/CORE_CONCEPTS.md b/docs/CORE_CONCEPTS.md index 5ac0b94..c24ff47 100644 --- a/docs/CORE_CONCEPTS.md +++ b/docs/CORE_CONCEPTS.md @@ -93,8 +93,13 @@ class SearchTool: | Strategy | Use Case | Trade-offs | |----------|----------|------------| -| **InProcessStrategy** | Fast, trusted tools | Speed ✅, Isolation ❌ | -| **IsolatedStrategy** | Untrusted or risky code | Isolation ✅, Speed ❌ | +| **InProcessStrategy** | Fast, trusted tools | Speed ✅, Crash isolation ❌ | +| **IsolatedStrategy** | Tools that may crash/hang | Crash isolation ✅, Speed ❌ | + +> `IsolatedStrategy` gives **crash/fault isolation** (separate worker processes), +> **not** a security boundary — workers are the same user and results cross via +> pickle. For untrusted or LLM-generated *code*, use +> [`IsolatedCodeRunner`](./isolated_execution.md), not a strategy. ### Parallel Execution @@ -125,21 +130,20 @@ async def main(): ) ) async with processor: - # Tools run in separate subprocesses (safe) + # Each tool call runs in a separate worker process (crash isolation) results = await processor.process(tool_calls) ``` -> **Note:** `IsolatedStrategy` is an alias of `SubprocessStrategy` for backwards compatibility. Use `IsolatedStrategy` for clarity—it better communicates the security boundary intent. +> **Note:** `IsolatedStrategy` is an alias of `SubprocessStrategy` for backwards compatibility. It provides crash/fault isolation for tool dispatch, **not** a security sandbox; for untrusted/LLM code use [`IsolatedCodeRunner`](./isolated_execution.md). ### When to Use Each Strategy -| Scenario | Recommended Strategy | -|----------|---------------------| -| Trusted internal tools | InProcessStrategy | -| External/user-provided code | IsolatedStrategy | -| LLM-generated code execution | IsolatedStrategy | -| Performance-critical path | InProcessStrategy | -| Tools that might crash | IsolatedStrategy | +| Scenario | Recommended | +|----------|-------------| +| Trusted internal tools | `InProcessStrategy` | +| Performance-critical path | `InProcessStrategy` | +| Tools that might crash or hang | `IsolatedStrategy` (crash isolation) | +| Untrusted / third-party / LLM-generated **code** | [`IsolatedCodeRunner`](./isolated_execution.md) — real isolation, not a strategy | --- From 2fb0d07e307259a7617d92bf2e89729cbbe7d62c Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 16:14:04 +0100 Subject: [PATCH 05/15] =?UTF-8?q?release:=20v0.24.0=20=E2=80=94=20isolated?= =?UTF-8?q?=20code=20execution=20(IsolatedCodeRunner=20+=20backends)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump version to 0.24.0 and add CHANGELOG entry for the isolated code execution feature (IsolatedCodeRunner + Seatbelt/Docker/bubblewrap/local backends). Additive — no changes to existing public APIs. Signed-off-by: chris hay --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dd6a6..1e0dc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [0.24.0] + +### Added + +- **`IsolatedCodeRunner`** — runs untrusted / LLM-generated code behind a real + OS/runtime boundary, with tool access brokered back to the host over a single + audited channel (JSON, never pickle). This is the safe counterpart to + `CodeSandbox`; see `docs/isolated_execution.md`. +- Isolation backends behind a common `IsolationBackend` protocol: + `SeatbeltBackend` (macOS `sandbox-exec`), `DockerBackend` (throwaway + container), `BubblewrapBackend` (Linux namespaces), and `LocalProcessBackend` + (no isolation; dev/testing only — the runner refuses it unless + `allow_no_isolation=True`). +- `IsolationLimits`, `IsolatedResult`, and the `IsolationBackend` protocol, + exported from `chuk_tool_processor.execution.isolation`. + +### Changed + +- Documentation now routes untrusted / LLM-generated code to + `IsolatedCodeRunner`, and no longer presents the subprocess `IsolatedStrategy` + as a security boundary — it provides crash/fault isolation for tool dispatch, + not isolation of an orchestration code string. + +### Notes + +- Experimental Windows (AppContainer) and WASM backends live on separate + branches and are not part of this release. + ## [0.23.0] ### Security diff --git a/pyproject.toml b/pyproject.toml index b7c054c..f71ab20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "chuk-tool-processor" -version = "0.23.0" +version = "0.24.0" description = "Async-native framework for registering, discovering, and executing tools referenced in LLM responses" readme = "README.md" requires-python = ">=3.11" From 9fce47dd14136644d88577673490f42c33974009 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 16:43:27 +0100 Subject: [PATCH 06/15] style: format isolation tests with ruff 0.15 to match CI Signed-off-by: chris hay --- tests/execution/isolation/test_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py index cca5d17..95960de 100644 --- a/tests/execution/isolation/test_backends.py +++ b/tests/execution/isolation/test_backends.py @@ -143,7 +143,7 @@ async def test_network_blocked(self): namespace="math", limits=IsolationLimits(wall_timeout=60.0), ) - code = "import socket\n" "socket.create_connection(('1.1.1.1', 53), timeout=3)\n" "return 'NET_OK'" + code = "import socket\nsocket.create_connection(('1.1.1.1', 53), timeout=3)\nreturn 'NET_OK'" r = await runner.run(code) assert r.ok is False From c9c7fd6c972ec92cd2774118854ce0cdcb632b58 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 16:45:22 +0100 Subject: [PATCH 07/15] build: sync uv.lock project version to 0.24.0 Signed-off-by: chris hay --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4d3d85c..aaac9d3 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "chuk-tool-processor" -version = "0.22.3" +version = "0.24.0" source = { editable = "." } dependencies = [ { name = "chuk-mcp" }, From d61a3fabe7bebef28672f4a3dacfaa8921c89bec Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 16:50:14 +0100 Subject: [PATCH 08/15] build: bump pre-commit ruff to v0.15.21 to match CI The pre-commit ruff was pinned to v0.7.1 while CI resolves ruff 0.15.x via 'uv run ruff', so the two disagreed on formatting and green local commits could still fail CI's 'ruff format --check .'. Align them. Signed-off-by: chris hay --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d973cd..f8f221d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: detect-private-key - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.15.21 hooks: - id: ruff args: [--fix] From 0e4156544891158acc6cac25e152634fd6b26711 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 16:58:53 +0100 Subject: [PATCH 09/15] test/ci: fix isolation tests under CI's pytest + Windows mypy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests/execution/isolation/__init__.py and put repo root on pytest pythonpath so 'from tests.…' cross-test imports resolve under the pytest console script (CI), not only 'python -m pytest'. - Set mypy platform=linux so POSIX-only APIs used behind runtime guards (os.killpg, resource, asyncio.start_unix_server) don't fail type-checking on the Windows runner; Windows-only modules keep their ignore_errors override. Signed-off-by: chris hay --- pyproject.toml | 9 ++++++++- tests/execution/isolation/__init__.py | 0 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 tests/execution/isolation/__init__.py diff --git a/pyproject.toml b/pyproject.toml index f71ab20..78f1d5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,9 @@ chuk_tool_processor = ["py.typed"] # pytest settings so it finds your src/ layout automatically [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"] +# "." puts the repo root on sys.path so cross-test imports (from tests.…) resolve +# under the `pytest` console script, not only `python -m pytest`. +pythonpath = ["src", "."] addopts = "-v --cov=src --cov-report=term --cov-report=xml --cov-report=html" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "strict" @@ -151,6 +153,11 @@ omit = [ [tool.mypy] python_version = "3.11" +# Analyze as Linux on every runner so POSIX-only APIs (os.killpg, resource, +# asyncio.start_unix_server, ...) used behind runtime platform guards don't +# fail type-checking on the Windows CI runner. Windows-only modules are covered +# by their own ignore_errors override below. +platform = "linux" # The isolation guest bootstrap is a standalone script copied into the guest at # runtime (it imports a sibling _wire.py, not the package); don't type-check it # as library code. diff --git a/tests/execution/isolation/__init__.py b/tests/execution/isolation/__init__.py new file mode 100644 index 0000000..e69de29 From 5b6c2982ba2928f5b2178370bfbdcfa3cd3abc75 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 19:22:12 +0100 Subject: [PATCH 10/15] test/ci: make isolation tests CI-safe (POSIX-only, gated integration) - Skip the isolation test modules on Windows: this release's broker uses unix domain sockets, so the feature is POSIX-only (Windows support is a later PR). - Gate Docker/bubblewrap integration tests behind CTP_TEST_ISOLATION_INTEGRATION so they don't run in the default matrix (ubuntu runners have Docker; the backend integration isn't verified in the mandatory suite). - Fix the cross-test import to a same-dir 'from test_runner import' (works under the pytest console script) and revert the global pythonpath change. - Keep mypy platform=linux so POSIX APIs behind runtime guards pass on Windows. Signed-off-by: chris hay --- pyproject.toml | 4 +--- tests/execution/isolation/__init__.py | 0 tests/execution/isolation/test_backends.py | 22 +++++++++++++++++++--- tests/execution/isolation/test_runner.py | 5 +++++ 4 files changed, 25 insertions(+), 6 deletions(-) delete mode 100644 tests/execution/isolation/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 78f1d5b..571c83c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,7 @@ chuk_tool_processor = ["py.typed"] # pytest settings so it finds your src/ layout automatically [tool.pytest.ini_options] testpaths = ["tests"] -# "." puts the repo root on sys.path so cross-test imports (from tests.…) resolve -# under the `pytest` console script, not only `python -m pytest`. -pythonpath = ["src", "."] +pythonpath = ["src"] addopts = "-v --cov=src --cov-report=term --cov-report=xml --cov-report=html" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "strict" diff --git a/tests/execution/isolation/__init__.py b/tests/execution/isolation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py index 95960de..5ed24f1 100644 --- a/tests/execution/isolation/test_backends.py +++ b/tests/execution/isolation/test_backends.py @@ -12,9 +12,13 @@ import os import shutil import subprocess +import sys import pytest +# Same directory (prepend import mode); avoids a global sys.path change. +from test_runner import ADD_LOOP, StubRegistry + from chuk_tool_processor.execution.isolation import ( BubblewrapBackend, DockerBackend, @@ -23,7 +27,13 @@ SeatbeltBackend, ) from chuk_tool_processor.execution.isolation.backend import GuestJob -from tests.execution.isolation.test_runner import ADD_LOOP, StubRegistry + +# Isolated execution is POSIX-only in this release; skip the module on Windows. +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="isolated execution is POSIX-only in this release") + +# Backend integration tests need a real runtime (Docker daemon / bwrap) AND spin +# up the broker; opt in explicitly so they don't run in the default CI matrix. +_RUN_INTEGRATION = os.environ.get("CTP_TEST_ISOLATION_INTEGRATION") == "1" def _job(**limit_kw) -> GuestJob: @@ -122,7 +132,10 @@ def _docker_up() -> bool: return False -@pytest.mark.skipif(not _docker_up(), reason="requires a running Docker daemon") +@pytest.mark.skipif( + not (_RUN_INTEGRATION and _docker_up()), + reason="set CTP_TEST_ISOLATION_INTEGRATION=1 with a running Docker daemon", +) class TestDockerIntegration: @pytest.mark.asyncio async def test_add_loop(self): @@ -148,7 +161,10 @@ async def test_network_blocked(self): assert r.ok is False -@pytest.mark.skipif(not BubblewrapBackend().is_available(), reason="requires Linux bwrap") +@pytest.mark.skipif( + not (_RUN_INTEGRATION and BubblewrapBackend().is_available()), + reason="set CTP_TEST_ISOLATION_INTEGRATION=1 on Linux with bwrap", +) class TestBubblewrapIntegration: @pytest.mark.asyncio async def test_add_loop(self): diff --git a/tests/execution/isolation/test_runner.py b/tests/execution/isolation/test_runner.py index f95fc45..4ba6837 100644 --- a/tests/execution/isolation/test_runner.py +++ b/tests/execution/isolation/test_runner.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import sys from dataclasses import dataclass import pytest @@ -24,6 +25,10 @@ _wire, ) +# Isolated execution is POSIX-only in this release (the broker uses unix domain +# sockets); skip the whole module on Windows. +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="isolated execution is POSIX-only in this release") + # --------------------------------------------------------------------------- # # Stub registry From 2578cc0d43f4e65bae877b7bd144733117ee3d5b Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 19:38:22 +0100 Subject: [PATCH 11/15] chore: temporary docker debug workflow (to be reverted) Signed-off-by: chris hay --- .github/workflows/_debug-docker.yml | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/_debug-docker.yml diff --git a/.github/workflows/_debug-docker.yml b/.github/workflows/_debug-docker.yml new file mode 100644 index 0000000..0977da1 --- /dev/null +++ b/.github/workflows/_debug-docker.yml @@ -0,0 +1,49 @@ +name: _debug-docker +on: + workflow_dispatch: + push: + branches: [feat/isolated-code-execution] + paths: [".github/workflows/_debug-docker.yml"] + +jobs: + debug: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - run: uv python install 3.12 + - run: uv sync --dev + - name: docker available? + run: docker version && docker info | head -20 + - name: run DockerBackend end-to-end (native Linux) + run: | + uv run python - <<'PY' + import asyncio + from dataclasses import dataclass + from chuk_tool_processor.execution.isolation import IsolatedCodeRunner, DockerBackend, IsolationLimits + + @dataclass + class Info: namespace: str; name: str + class AddTool: + async def execute(self, a, b): return {"sum": int(a)+int(b)} + class Reg: + async def list_tools(self, namespace=None): return [Info("math","add")] + async def get_tool(self, name, namespace="default"): return AddTool() if name=="add" else None + + ADD_LOOP = "total = 0\nfor i in range(1, 6):\n r = await add(a=str(total), b=str(i))\n total = r['sum']\nreturn total\n" + + async def main(): + b = DockerBackend(docker_bin="docker") + print("is_available:", b.is_available()) + runner = IsolatedCodeRunner(b, registry=Reg(), namespace="math", limits=IsolationLimits(wall_timeout=120)) + r = await runner.run(ADD_LOOP) + print("OK:", r.ok, "VALUE:", r.value, "TOOLCALLS:", r.tool_calls) + print("ERROR_TYPE:", r.error_type) + print("ERROR:", r.error) + print("STDERR:\n", r.stderr) + print("STDOUT:\n", r.stdout) + asyncio.run(main()) + PY From 73393090dbfc2a3724bf8d546d976dce2f060c8f Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 19:42:27 +0100 Subject: [PATCH 12/15] fix(docker): pre-pull image before the sandboxed run (--pull never) Signed-off-by: chris hay --- .../execution/isolation/backends/docker.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/chuk_tool_processor/execution/isolation/backends/docker.py b/src/chuk_tool_processor/execution/isolation/backends/docker.py index 6686dd5..00c6315 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/docker.py +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -66,6 +66,10 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: "run", "--rm", "-i", + # Image is pre-pulled in run_guest; never pull during the sandboxed + # run (a --network none run can't reach a registry anyway). + "--pull", + "never", "--name", self._container_name(job), "--read-only", @@ -96,6 +100,11 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: return argv async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + # Acquire the image up front (with the daemon's network) so the sandboxed + # `docker run --network none --pull never` never has to reach a registry. + pull_error = await self._ensure_image() + if pull_error: + return GuestOutcome(exit_code=1, stderr=pull_error, timed_out=False) # Killing the docker client on timeout does not stop the container, so # force-remove by name in a finally (name is derived from the unique # per-run token, making this concurrency-safe). @@ -104,6 +113,31 @@ async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutco finally: await self._force_remove(self._container_name(job)) + async def _ensure_image(self) -> str | None: + """Ensure ``self.image`` is present locally; pull it if not. Returns an error string on failure.""" + inspect = await asyncio.create_subprocess_exec( + self.docker_bin, + "image", + "inspect", + self.image, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + if await inspect.wait() == 0: + return None + pull = await asyncio.create_subprocess_exec( + self.docker_bin, + "pull", + self.image, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _out, err = await pull.communicate() + if pull.returncode != 0: + detail = (err or b"").decode(errors="replace").strip()[:400] + return f"failed to pull image {self.image!r}: {detail}" + return None + async def _force_remove(self, name: str) -> None: with contextlib.suppress(Exception): proc = await asyncio.create_subprocess_exec( From c09e1abeb51ea8c24819cf8c251b6424bb58386b Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 19:48:44 +0100 Subject: [PATCH 13/15] fix(docker): run container as host uid (readable staging under cap-drop ALL) Signed-off-by: chris hay --- .../execution/isolation/backends/docker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/chuk_tool_processor/execution/isolation/backends/docker.py b/src/chuk_tool_processor/execution/isolation/backends/docker.py index 00c6315..54a117c 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/docker.py +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -81,6 +81,10 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: "no-new-privileges", "--cpus", str(self.cpus), + # Run as the host uid so the container (with no CAP_DAC_OVERRIDE) can + # read the 0700 staging dir and reach the broker socket dir, and so + # the guest runs unprivileged. POSIX host only. + *(["--user", f"{os.getuid()}:{os.getgid()}"] if hasattr(os, "getuid") else []), "-e", "PYTHONDONTWRITEBYTECODE=1", ] From ea6fe32b385de233013f967672aef825e3ca61cb Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 11:06:38 +0100 Subject: [PATCH 14/15] ci/docs: verify DockerBackend integration on Linux; note Desktop limitation - Add isolation.yml: runs the Docker + bubblewrap integration tests on ubuntu (CTP_TEST_ISOLATION_INTEGRATION=1) so the container backend is CI-verified. - Remove the temporary docker-debug workflow. - docs: DockerBackend is CI-verified end-to-end on native Linux and runs as the host uid; note that Docker Desktop / podman-machine VM file sharing does not support the bind-mounted unix socket (ENOTSUP). Signed-off-by: chris hay --- .github/workflows/_debug-docker.yml | 49 ----------------------------- .github/workflows/isolation.yml | 37 ++++++++++++++++++++++ docs/isolated_execution.md | 10 +++++- 3 files changed, 46 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/_debug-docker.yml create mode 100644 .github/workflows/isolation.yml diff --git a/.github/workflows/_debug-docker.yml b/.github/workflows/_debug-docker.yml deleted file mode 100644 index 0977da1..0000000 --- a/.github/workflows/_debug-docker.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: _debug-docker -on: - workflow_dispatch: - push: - branches: [feat/isolated-code-execution] - paths: [".github/workflows/_debug-docker.yml"] - -jobs: - debug: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v8.3.2 - with: - enable-cache: true - cache-dependency-glob: "uv.lock" - - run: uv python install 3.12 - - run: uv sync --dev - - name: docker available? - run: docker version && docker info | head -20 - - name: run DockerBackend end-to-end (native Linux) - run: | - uv run python - <<'PY' - import asyncio - from dataclasses import dataclass - from chuk_tool_processor.execution.isolation import IsolatedCodeRunner, DockerBackend, IsolationLimits - - @dataclass - class Info: namespace: str; name: str - class AddTool: - async def execute(self, a, b): return {"sum": int(a)+int(b)} - class Reg: - async def list_tools(self, namespace=None): return [Info("math","add")] - async def get_tool(self, name, namespace="default"): return AddTool() if name=="add" else None - - ADD_LOOP = "total = 0\nfor i in range(1, 6):\n r = await add(a=str(total), b=str(i))\n total = r['sum']\nreturn total\n" - - async def main(): - b = DockerBackend(docker_bin="docker") - print("is_available:", b.is_available()) - runner = IsolatedCodeRunner(b, registry=Reg(), namespace="math", limits=IsolationLimits(wall_timeout=120)) - r = await runner.run(ADD_LOOP) - print("OK:", r.ok, "VALUE:", r.value, "TOOLCALLS:", r.tool_calls) - print("ERROR_TYPE:", r.error_type) - print("ERROR:", r.error) - print("STDERR:\n", r.stderr) - print("STDOUT:\n", r.stdout) - asyncio.run(main()) - PY diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml new file mode 100644 index 0000000..2f429d0 --- /dev/null +++ b/.github/workflows/isolation.yml @@ -0,0 +1,37 @@ +name: Isolation integration + +# Runs the isolation backend integration tests (opt-in) against real runtimes. +# The default matrix (test.yml) only covers the pure/unit tests; this job spins +# up real Docker containers so the DockerBackend is verified end-to-end. + +on: + push: + branches: [main, develop, "feat/**"] + paths: + - "src/chuk_tool_processor/execution/isolation/**" + - "tests/execution/isolation/**" + - ".github/workflows/isolation.yml" + pull_request: + paths: + - "src/chuk_tool_processor/execution/isolation/**" + - "tests/execution/isolation/**" + workflow_dispatch: + +jobs: + integration: + runs-on: ubuntu-latest + env: + # Opt in to the backend integration tests (real Docker daemon on ubuntu). + CTP_TEST_ISOLATION_INTEGRATION: "1" + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - run: uv python install 3.12 + - run: uv sync --dev + - name: Install bubblewrap (Linux namespace backend) + run: sudo apt-get update && sudo apt-get install -y bubblewrap + - name: Isolation tests (Docker + bubblewrap integration enabled) + run: uv run pytest tests/execution/isolation/ -v -o addopts="" diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md index daec202..1857b13 100644 --- a/docs/isolated_execution.md +++ b/docs/isolated_execution.md @@ -81,11 +81,19 @@ non-isolating backend unless you pass `allow_no_isolation=True`. | Backend | Isolation | Platform | Needs | Notes | |---|---|---|---|---| -| `DockerBackend` | Strong | any w/ Docker/Podman | `docker` CLI + daemon | throwaway container, `--network none`, read-only root, dropped caps | +| `DockerBackend` | Strong§ | Linux Docker host | `docker`/`podman` CLI + daemon | throwaway container, `--network none`, read-only root, dropped caps, runs as host uid | | `SeatbeltBackend` | Strong* | macOS | `sandbox-exec` (built in) | no inet, no fs-writes outside work/tmp, secret dirs unreadable | | `BubblewrapBackend` | Strong | Linux | `bwrap` binary | user/mount/pid/net namespaces | | `LocalProcessBackend` | **None** | any | — | dev/testing only; runner refuses it without `allow_no_isolation=True` | +§ `DockerBackend` runs each guest in a throwaway `docker run --rm` container +(pre-pulled image + `--pull never`, `--network none`, read-only root, `--cap-drop +ALL`, memory/pids limits) **as the host uid**, and is CI-verified end-to-end on +native Linux. The host↔guest broker uses a bind-mounted unix socket, which the +VM-based file sharing in **Docker Desktop / podman-machine (macOS, Windows)** +does not support (`connect()` returns `ENOTSUP`) — run it on a native Linux +Docker host (servers, CI, WSL2). + \* Seatbelt reliably blocks network and filesystem *writes*; read confinement is best-effort (broad reads with known secret dirs denied) because a strict read allowlist aborts CPython. The denied secret paths are configurable — From cafd6acd236453539ceaaf19145d6ea1a4fd7a8c Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 11:15:16 +0100 Subject: [PATCH 15/15] ci/docs: don't run bubblewrap integration in CI (runner blocks netns loopback) Docker integration is CI-verified; bubblewrap needs a real Linux host with unprivileged user namespaces (GitHub runners reject bwrap's loopback RTM_NEWADDR). Signed-off-by: chris hay --- .github/workflows/isolation.yml | 8 +++++--- docs/isolated_execution.md | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml index 2f429d0..381bcbc 100644 --- a/.github/workflows/isolation.yml +++ b/.github/workflows/isolation.yml @@ -31,7 +31,9 @@ jobs: cache-dependency-glob: "uv.lock" - run: uv python install 3.12 - run: uv sync --dev - - name: Install bubblewrap (Linux namespace backend) - run: sudo apt-get update && sudo apt-get install -y bubblewrap - - name: Isolation tests (Docker + bubblewrap integration enabled) + # NB: bubblewrap is intentionally NOT installed here — GitHub runners block + # the netlink call bwrap uses to bring up loopback in a new net namespace + # ("RTM_NEWADDR: Operation not permitted"), so its integration test can't + # run here. It requires a real Linux host with unprivileged user namespaces. + - name: Isolation tests (Docker integration enabled) run: uv run pytest tests/execution/isolation/ -v -o addopts="" diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md index 1857b13..d8765f9 100644 --- a/docs/isolated_execution.md +++ b/docs/isolated_execution.md @@ -83,7 +83,7 @@ non-isolating backend unless you pass `allow_no_isolation=True`. |---|---|---|---|---| | `DockerBackend` | Strong§ | Linux Docker host | `docker`/`podman` CLI + daemon | throwaway container, `--network none`, read-only root, dropped caps, runs as host uid | | `SeatbeltBackend` | Strong* | macOS | `sandbox-exec` (built in) | no inet, no fs-writes outside work/tmp, secret dirs unreadable | -| `BubblewrapBackend` | Strong | Linux | `bwrap` binary | user/mount/pid/net namespaces | +| `BubblewrapBackend` | Strong¶ | Linux | `bwrap` binary | user/mount/pid/net namespaces | | `LocalProcessBackend` | **None** | any | — | dev/testing only; runner refuses it without `allow_no_isolation=True` | § `DockerBackend` runs each guest in a throwaway `docker run --rm` container @@ -94,6 +94,11 @@ VM-based file sharing in **Docker Desktop / podman-machine (macOS, Windows)** does not support (`connect()` returns `ENOTSUP`) — run it on a native Linux Docker host (servers, CI, WSL2). +¶ `BubblewrapBackend` requires a Linux host with **unprivileged user namespaces** +enabled. It is not exercised in GitHub CI because the runners block the netlink +call `bwrap` uses to bring up loopback in a fresh network namespace +(`RTM_NEWADDR: Operation not permitted`); verify it on a real Linux host. + \* Seatbelt reliably blocks network and filesystem *writes*; read confinement is best-effort (broad reads with known secret dirs denied) because a strict read allowlist aborts CPython. The denied secret paths are configurable —