diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml new file mode 100644 index 0000000..381bcbc --- /dev/null +++ b/.github/workflows/isolation.yml @@ -0,0 +1,39 @@ +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 + # 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/.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] 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/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 | --- diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md new file mode 100644 index 0000000..d8765f9 --- /dev/null +++ b/docs/isolated_execution.md @@ -0,0 +1,165 @@ +# 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§ | 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). + +¶ `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 — +`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 diff --git a/pyproject.toml b/pyproject.toml index 247d1b0..571c83c 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" @@ -151,6 +151,15 @@ 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. +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..6b0ba3a --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/__init__.py @@ -0,0 +1,44 @@ +# 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 ( + 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 + +__all__ = [ + # Runner + data types + "IsolatedCodeRunner", + "IsolationLimits", + "IsolatedResult", + # Backend protocol + errors + "IsolationBackend", + "IsolationError", + "BackendUnavailableError", + "GuestJob", + "GuestOutcome", + # Backends + "LocalProcessBackend", + "SeatbeltBackend", + "DockerBackend", + "BubblewrapBackend", +] 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..0641d8b --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/__init__.py @@ -0,0 +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/_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/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..54a117c --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -0,0 +1,155 @@ +# 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", + # 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", + "--tmpfs", + "/tmp", + "--cap-drop", + "ALL", + "--security-opt", + "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", + ] + 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: + # 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). + try: + return await super().run_guest(job, host_socket_path=host_socket_path) + 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( + 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/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..90c5945 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py @@ -0,0 +1,114 @@ +# 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 collections.abc import Sequence + +from chuk_tool_processor.execution.isolation.backend import GuestJob +from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx + +# 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", +) + + +def _q(path: str) -> str: + """Quote a path for an SBPL string literal.""" + 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 + + 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) + 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 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"))', + # 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_backends.py b/tests/execution/isolation/test_backends.py new file mode 100644 index 0000000..5ed24f1 --- /dev/null +++ b/tests/execution/isolation/test_backends.py @@ -0,0 +1,178 @@ +# 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 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, + IsolatedCodeRunner, + IsolationLimits, + SeatbeltBackend, +) +from chuk_tool_processor.execution.isolation.backend import GuestJob + +# 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: + 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 (_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): + 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\nsocket.create_connection(('1.1.1.1', 53), timeout=3)\nreturn 'NET_OK'" + r = await runner.run(code) + assert r.ok is False + + +@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): + 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 diff --git a/tests/execution/isolation/test_runner.py b/tests/execution/isolation/test_runner.py new file mode 100644 index 0000000..4ba6837 --- /dev/null +++ b/tests/execution/isolation/test_runner.py @@ -0,0 +1,224 @@ +# 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 +import sys +from dataclasses import dataclass + +import pytest + +from chuk_tool_processor.execution.isolation import ( + IsolatedCodeRunner, + IsolationError, + IsolationLimits, + LocalProcessBackend, + SeatbeltBackend, + _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 +# --------------------------------------------------------------------------- # +@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 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" },