Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions .github/workflows/isolation.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
name: Isolation integration
name: Isolation backends

# 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.
# Exercises the isolation backends against real runtimes, separate from the
# mandatory matrix so experimental backends don't block it while they are being
# verified:
# * Linux — Docker containers (CTP_TEST_ISOLATION_INTEGRATION)
# * Windows — experimental named-pipe transport + AppContainer backend
# (CTP_TEST_ISOLATION_WINDOWS + pywin32)

on:
push:
Expand All @@ -18,22 +21,34 @@ on:
workflow_dispatch:

jobs:
integration:
runs-on: ubuntu-latest
isolation:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
env:
# Opt in to the backend integration tests (real Docker daemon on ubuntu).
# Docker integration (Linux) + experimental Windows path. Each only takes
# effect where its runtime is present; harmless elsewhere.
CTP_TEST_ISOLATION_INTEGRATION: "1"
CTP_TEST_ISOLATION_WINDOWS: "1"
CTP_GUEST_DEBUG: "1" # guest prints connection checkpoints to stderr
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v8.3.2
- name: Install uv
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)
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --dev
- name: Install pywin32 (Windows named-pipe transport)
if: runner.os == 'Windows'
run: uv pip install pywin32
# NB: bubblewrap is intentionally NOT installed — GitHub runners block the
# netlink call bwrap uses to bring up loopback in a new net namespace
# ("RTM_NEWADDR: Operation not permitted"); it needs a real Linux host.
- name: Run isolation tests
run: uv run pytest tests/execution/isolation/ -v -o addopts=""
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,54 @@
All notable changes to this project are documented here. This project follows
[Semantic Versioning](https://semver.org/).

## [0.25.0]

### Security

- **Broker namespace policy is now authoritative.** The isolated guest runs
untrusted code and holds the broker token, so it can craft raw protocol frames.
`call_tool` previously let a guest-supplied namespace override the host's, so a
run pinned to one namespace could reach tools in another. The host namespace is
now enforced: a mismatching guest-supplied namespace is rejected, and tool
resolution is pinned to that namespace (no cross-namespace fuzzy fallback).
- **Namespace-qualified allowlists.** `allowed_tools` entries may be bare
(`"name"`) or qualified (`"namespace.name"`); qualified entries pin a tool to a
single namespace so an allowed name can't select a same-named tool elsewhere.
- **Brokered tool calls run through the canonical executor.** Instead of invoking
`tool.execute()` directly, the broker routes calls through the normal
`ToolExecutor` path, so sandboxed calls get the same wrappers, guards, and
observability as any other call. Callers may inject a fully-wrapped executor.
- **Bounded broker lifecycle.** In-flight host tool calls are tracked and
cancelled when the run ends (`aclose`), and no tool calls are honoured once the
guest has reported its result — a departing or timed-out guest can no longer
leave privileged host work running.

### Changed

- `IsolationLimits` and `IsolatedResult` are now Pydantic models (frozen +
`extra="forbid"` for limits) with field-level validation, replacing dataclasses.

### Added

- **Experimental Windows AppContainer backend** (`WindowsBackend`) — the Windows
analogue of the macOS Seatbelt backend. Launches the guest as a
capability-restricted, low-integrity AppContainer process inside a Job Object
(memory / active-process caps, whole-tree kill on close). With no capabilities
granted, the guest has no network and no access to the user's files; it reaches
the host only through the broker. Verified via the dedicated Windows isolation
CI. Install with the `isolation-windows` extra (pulls in `pywin32`).
- **Pluggable broker transport.** The host↔guest broker channel is now an
abstraction with two implementations: a unix-domain socket on POSIX and a
**named pipe** on Windows (overlapped I/O; ACL grants `ALL APPLICATION
PACKAGES` at low integrity so an AppContainer guest can connect over local IPC,
not the network). The job payload carries both `endpoint` and `transport`.

### Notes

- The Windows backend is experimental and exercised only through Windows CI; its
capability set and output capture may still change. On non-Windows hosts it is
import-safe and reports `is_available() == False`.

## [0.24.0]

### Added
Expand Down
55 changes: 43 additions & 12 deletions docs/isolated_execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ 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.)
Linux bubblewrap / Windows AppContainer 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?

Expand Down Expand Up @@ -82,8 +82,9 @@ non-isolating backend unless you pass `allow_no_isolation=True`.
| 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 |
| `SeatbeltBackend` | Net + write* | macOS | `sandbox-exec` (built in) | blocks network and fs-writes outside work/tmp; reads are broad with a secret-dir denylist |
| `BubblewrapBackend` | Strong¶ | Linux | `bwrap` binary | user/mount/pid/net namespaces |
| `WindowsBackend` | Strong‡ | Windows | `pywin32` | AppContainer + Job Object (+ low integrity) |
| `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
Expand All @@ -99,18 +100,48 @@ 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 —
\* Seatbelt is **network and write confinement with partial read protection**,
not full isolation: it reliably blocks network and filesystem *writes*, but 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, …).
`DEFAULT_DENY_READ_PATHS` (`~/.ssh`, `~/.aws`, cloud creds, keychains, …). Note
that `sandbox-exec` is officially deprecated by Apple (still present and
functional on current macOS); prefer `DockerBackend` where you need a stronger,
non-deprecated boundary.
`sandbox-exec` is deprecated by Apple but functional.

‡ `WindowsBackend` is **experimental** — the AppContainer + Job Object launch and
the named-pipe broker transport are verified via Windows CI, not on the author's
machine; see below.

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).
dependencies (they shell out to the respective binary). Windows needs
`pip install chuk-tool-processor[isolation-windows]` (pywin32).

## Windows backend (experimental)

`WindowsBackend` is the Windows analogue of Seatbelt:

macOS Seatbelt profile ≈ Windows AppContainer + Job Object (+ low integrity)

- The guest runs as an **AppContainer** with **no capabilities** — so no network
and no access to the user's files by construction — at low integrity.
- It is placed in a **Job Object** that caps memory and active processes and
kills the whole tree on close.
- Because AppContainers can't use unix sockets and loopback is blocked for them
without a network-capability hole, the broker channel is a **named pipe** whose
security descriptor grants `ALL APPLICATION PACKAGES` (a local IPC object, not
the network). The staging dir is granted to the same SID (via `icacls`) so the
guest can read the bootstrap and write its output.

It is availability-gated on Windows + `pywin32`, so `is_available()` is `False`
elsewhere and the runner won't select it. The implementation is a first draft
verified through the `isolation` GitHub Actions workflow (which installs pywin32
and sets `CTP_TEST_ISOLATION_WINDOWS=1`); details may change as CI exercises it.

## Resource limits

Expand All @@ -131,9 +162,9 @@ dependencies (they shell out to the respective binary).
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.
namespace, Seatbelt, or AppContainer). Even a full `CodeSandbox`-style
`__subclasses__()` escape only reaches the *guest's* interpreter, which has no
host access beyond the broker channel.
- **Reaching tools you didn't expose** → `ToolBroker` allowlist + namespace.
- **Tool-call flooding** → `max_tool_calls`.
- **Network exfiltration** → `allow_network=False` (default).
Expand Down
6 changes: 4 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ result = await runner.run(untrusted_code) # no network, no host fs, tools brok

Backends, in roughly increasing isolation strength:

1. **`SeatbeltBackend`** (macOS) / **`BubblewrapBackend`** (Linux) — OS sandbox:
resource limits, no network, filesystem confined, only the broker channel open.
1. **`SeatbeltBackend`** (macOS) / **`BubblewrapBackend`** (Linux) /
**`WindowsBackend`** (Windows AppContainer + Job Object, experimental) — 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.
Expand Down
41 changes: 40 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "chuk-tool-processor"
version = "0.24.0"
version = "0.25.0"
description = "Async-native framework for registering, discovering, and executing tools referenced in LLM responses"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -53,6 +53,12 @@ redis = [
"redis[hiredis]>=8,<9",
]

# Windows AppContainer isolation backend (experimental). Needs pywin32 for the
# named-pipe broker transport's security descriptor. Only installs on Windows.
isolation-windows = [
"pywin32>=306; sys_platform == 'win32'",
]

# Full feature set with performance optimizations
full = [
"orjson>=3.10.0,<4",
Expand Down Expand Up @@ -127,6 +133,14 @@ omit = [
"*/__main__.py",
"*/sample_tools/*",
"src/sample_tools/*",
# Isolation guest/Windows modules: exercised by the dedicated isolation CI
# (isolation.yml: Docker on Linux, AppContainer + named pipe on Windows), not
# the Linux unit-coverage run. The guest bootstrap runs in a separate
# process/container, so parent-process coverage never sees it on any platform.
"*/isolation/guest_bootstrap.py",
"*/isolation/_winpipe.py",
"*/isolation/_winproc.py",
"*/isolation/backends/windows.py",
]

[tool.coverage.report]
Expand All @@ -147,6 +161,10 @@ precision = 2
omit = [
"*/sample_tools/*",
"src/sample_tools/*",
"*/isolation/guest_bootstrap.py",
"*/isolation/_winpipe.py",
"*/isolation/_winproc.py",
"*/isolation/backends/windows.py",
]

[tool.mypy]
Expand Down Expand Up @@ -187,6 +205,17 @@ module = [
"httpx_sse.*",
"opentelemetry.*",
"prometheus_client.*",
# Windows-only (pywin32); present only on Windows / in the windows CI job.
"win32api",
"win32con",
"win32event",
"win32file",
"win32job",
"win32pipe",
"win32process",
"win32security",
"pywintypes",
"ntsecuritycon",
]
ignore_missing_imports = true

Expand All @@ -195,6 +224,16 @@ ignore_missing_imports = true
module = ["tests.*", "sample_tools.*"]
ignore_errors = true

# Experimental Windows-only backend modules: use Win32 ctypes/pywin32 APIs that
# typeshed only exposes on win32, and are verified via Windows CI, not mypy here.
[[tool.mypy.overrides]]
module = [
"chuk_tool_processor.execution.isolation._winpipe",
"chuk_tool_processor.execution.isolation._winproc",
"chuk_tool_processor.execution.isolation.backends.windows",
]
ignore_errors = true

# TODO: Gradually reduce these overrides as code is improved
# Be more lenient for complex MCP integration code (for now)
[[tool.mypy.overrides]]
Expand Down
24 changes: 21 additions & 3 deletions roadmap.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
# CHUK Tool Processor — Roadmap

> Last updated: 2026-02-21
> Last updated: 2026-07-29

---

## Shipped (v0.22)
## Shipped (through v0.25)

Everything below is production-ready on `main`.

### Isolated Execution & Security (v0.23–v0.25)
- [x] **Fail-closed `CodeSandbox`** (v0.23) — the in-process `exec()` sandbox no
longer pretends restricted builtins are a security boundary; it refuses to
run untrusted code unless `allow_unsafe_execution=True` and is documented
as trusted-code-only
- [x] **`IsolatedCodeRunner`** (v0.24) — runs untrusted / LLM-generated code
behind a real OS/runtime boundary, with tool access brokered back to the
host over a single audited JSON channel (never pickle)
- [x] Isolation backends behind one protocol — `SeatbeltBackend` (macOS),
`DockerBackend` (throwaway container), `BubblewrapBackend` (Linux
namespaces), `LocalProcessBackend` (dev/testing; refused without opt-in)
- [x] **Experimental Windows AppContainer backend** + pluggable broker transport
(unix socket / Windows named pipe) (v0.25)
- [x] **Broker policy hardening** (v0.25) — host-authoritative namespace,
namespace-qualified allowlists, calls routed through the canonical executor,
and bounded lifecycle (in-flight calls cancelled on close; none honoured
after the run's result)

### Core Runtime
- [x] Async-native tool execution (Python 3.11+)
- [x] Multi-format parsing — Anthropic XML, OpenAI `tool_calls`, JSON
Expand Down Expand Up @@ -96,7 +114,7 @@ Comprehensive audit and fix of architecture principle violations:

---

## Next — Features (v0.23)
## Next — Features

### Guard Integration with Processor
Guards are already built as a standalone `GuardChain` system with pre/post-execution hooks. The integration into the processor pipeline should be clean since the API was designed for exactly this wiring.
Expand Down
2 changes: 2 additions & 0 deletions src/chuk_tool_processor/execution/isolation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DockerBackend,
LocalProcessBackend,
SeatbeltBackend,
WindowsBackend,
)
from chuk_tool_processor.execution.isolation.limits import IsolationLimits
from chuk_tool_processor.execution.isolation.result import IsolatedResult
Expand All @@ -41,4 +42,5 @@
"SeatbeltBackend",
"DockerBackend",
"BubblewrapBackend",
"WindowsBackend",
]
Loading
Loading