From c16704f3179a3b91f7954bc0cc9e1d938e298542 Mon Sep 17 00:00:00 2001 From: chris hay Date: Tue, 28 Jul 2026 13:07:30 +0100 Subject: [PATCH 01/12] feat(execution): experimental Windows AppContainer isolation backend Add Windows support to the isolated code runner, behind a pluggable broker transport so POSIX is unchanged. Transport groundwork (verified on macOS/Linux): - Pluggable broker transport with a MessageChannel abstraction: unix domain socket on POSIX (unchanged), Windows named pipe otherwise. Broker, guest bootstrap, backends, and runner now speak endpoint+transport instead of a hard-coded socket path. Windows (experimental, verified via CI, not locally): - _winpipe: host named-pipe server whose security descriptor grants ALL APPLICATION PACKAGES + a low-integrity label, so a low-IL AppContainer can reach it over local IPC without any network hole (pywin32). - _winproc: AppContainer launch via STARTUPINFOEX security-capabilities + CreateProcess, inside a Job Object capping memory/processes and killing the tree on close (ctypes). - WindowsBackend: ties them together (AppContainer + Job Object + low integrity); import-safe on POSIX, is_available() true only on Windows. Packaging/CI: - isolation-windows extra (pywin32); Windows-only mypy overrides. - New .github/workflows/isolation.yml matrix (ubuntu/macos/windows) that installs pywin32 + bubblewrap and runs the isolation tests with the Windows path opted in via CTP_TEST_ISOLATION_WINDOWS=1. The experimental Windows broker/backend tests are gated on that opt-in so they don't block the mandatory suite. - Docs: Windows backend section + backend table/security-model updates. Signed-off-by: chris hay --- .github/workflows/isolation.yml | 44 ++-- docs/isolated_execution.md | 41 ++- docs/security.md | 6 +- pyproject.toml | 27 ++ .../execution/isolation/__init__.py | 2 + .../execution/isolation/_winpipe.py | 177 +++++++++++++ .../execution/isolation/_winproc.py | 235 ++++++++++++++++++ .../execution/isolation/backend.py | 24 +- .../execution/isolation/backends/__init__.py | 2 + .../isolation/backends/_subprocess.py | 16 +- .../isolation/backends/bubblewrap.py | 4 +- .../execution/isolation/backends/docker.py | 14 +- .../execution/isolation/backends/seatbelt.py | 2 +- .../execution/isolation/backends/windows.py | 179 +++++++++++++ .../execution/isolation/broker.py | 82 +++--- .../execution/isolation/guest_bootstrap.py | 30 ++- .../execution/isolation/runner.py | 4 +- .../execution/isolation/transport.py | 128 ++++++++++ tests/execution/isolation/test_backends.py | 54 +++- tests/execution/isolation/test_runner.py | 15 +- uv.lock | 28 ++- 21 files changed, 1001 insertions(+), 113 deletions(-) create mode 100644 src/chuk_tool_processor/execution/isolation/_winpipe.py create mode 100644 src/chuk_tool_processor/execution/isolation/_winproc.py create mode 100644 src/chuk_tool_processor/execution/isolation/backends/windows.py create mode 100644 src/chuk_tool_processor/execution/isolation/transport.py diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml index 381bcbc..3a95347 100644 --- a/.github/workflows/isolation.yml +++ b/.github/workflows/isolation.yml @@ -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: @@ -18,22 +21,33 @@ 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" 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="" diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md index d8765f9..78e90d9 100644 --- a/docs/isolated_execution.md +++ b/docs/isolated_execution.md @@ -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? @@ -84,6 +84,7 @@ non-isolating backend unless you pass `allow_no_isolation=True`. | `DockerBackend` | Strong§ | Linux Docker host | `docker`/`podman` CLI + daemon | throwaway container, `--network none`, read-only root, dropped caps, runs as host uid | | `SeatbeltBackend` | Strong* | macOS | `sandbox-exec` (built in) | no inet, no fs-writes outside work/tmp, secret dirs unreadable | | `BubblewrapBackend` | Strong¶ | Linux | `bwrap` binary | user/mount/pid/net namespaces | +| `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 @@ -106,11 +107,37 @@ allowlist aborts CPython. The denied secret paths are configurable — `DEFAULT_DENY_READ_PATHS` (`~/.ssh`, `~/.aws`, cloud creds, keychains, …). `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 @@ -131,9 +158,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). diff --git a/docs/security.md b/docs/security.md index c795113..339195e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 571c83c..baa5939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -187,6 +193,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 @@ -195,6 +212,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]] diff --git a/src/chuk_tool_processor/execution/isolation/__init__.py b/src/chuk_tool_processor/execution/isolation/__init__.py index 6b0ba3a..39b7dbc 100644 --- a/src/chuk_tool_processor/execution/isolation/__init__.py +++ b/src/chuk_tool_processor/execution/isolation/__init__.py @@ -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 @@ -41,4 +42,5 @@ "SeatbeltBackend", "DockerBackend", "BubblewrapBackend", + "WindowsBackend", ] diff --git a/src/chuk_tool_processor/execution/isolation/_winpipe.py b/src/chuk_tool_processor/execution/isolation/_winpipe.py new file mode 100644 index 0000000..6ddcb9e --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/_winpipe.py @@ -0,0 +1,177 @@ +# chuk_tool_processor/execution/isolation/_winpipe.py +""" +Windows named-pipe broker transport (EXPERIMENTAL). + +Provides the host side of the tool-broker channel on Windows, where unix sockets +don't exist and TCP loopback is blocked for AppContainers without a network hole. +A named pipe is created with a security descriptor that grants: + + * the current user + SYSTEM + Administrators full control, and + * ``ALL APPLICATION PACKAGES`` (AC) read/write, plus a **low integrity** + label (``S:(ML;;NW;;;LW)``) + +so a low-integrity AppContainer child can connect to it by name — over a local +IPC object, not the network. Uses ``pywin32``; imported lazily and only on +Windows. + +.. warning:: + Experimental and verified only via Windows CI. Blocking pipe I/O runs in the + default thread-pool executor; each message is a 4-byte big-endian length + prefix followed by JSON (same framing as ``_wire``). +""" + +from __future__ import annotations + +import asyncio +import struct +import threading +import uuid +from typing import Any + +import pywintypes +import win32api +import win32con +import win32file +import win32pipe +import win32security + +from chuk_tool_processor.execution.isolation import _wire +from chuk_tool_processor.execution.isolation.transport import BrokerListener, ClientHandler, MessageChannel + +_LEN = struct.Struct(">I") +_BUF = 65536 + + +def _pipe_security_attributes() -> Any: + """SECURITY_ATTRIBUTES granting the user + ALL APPLICATION PACKAGES at low IL.""" + token = win32security.OpenProcessToken(win32api.GetCurrentProcess(), win32con.TOKEN_QUERY) + user_sid = win32security.GetTokenInformation(token, win32security.TokenUser)[0] + user = win32security.ConvertSidToStringSid(user_sid) + # GA=GENERIC_ALL, GRGW=read+write; AC=ALL APPLICATION PACKAGES; ML/LW=low integrity. + sddl = ( + "D:" + "(A;;GA;;;SY)" # SYSTEM + "(A;;GA;;;BA)" # Administrators + f"(A;;GA;;;{user})" # current user + "(A;;GRGW;;;AC)" # ALL APPLICATION PACKAGES: read/write + "S:(ML;;NW;;;LW)" # low integrity label, no write-up restriction lifted for AC + ) + sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor(sddl, win32security.SDDL_REVISION_1) + sa = win32security.SECURITY_ATTRIBUTES() + sa.SECURITY_DESCRIPTOR = sd + sa.bInheritHandle = False + return sa + + +class _PipeChannel(MessageChannel): + """MessageChannel over a connected named-pipe instance handle (blocking I/O in executor).""" + + def __init__(self, handle: Any) -> None: + self._handle = handle + self._write_lock = asyncio.Lock() + + def _read_exact(self, n: int) -> bytes: + chunks = bytearray() + while len(chunks) < n: + _hr, data = win32file.ReadFile(self._handle, n - len(chunks)) + if not data: + raise ConnectionError("pipe closed") + chunks.extend(data) + return bytes(chunks) + + def _recv_blocking(self) -> dict[str, Any]: + (length,) = _LEN.unpack(self._read_exact(_LEN.size)) + body = self._read_exact(length) + import json + + return json.loads(body.decode("utf-8")) + + async def recv(self) -> dict[str, Any]: + try: + return await asyncio.get_running_loop().run_in_executor(None, self._recv_blocking) + except pywintypes.error as exc: + raise ConnectionError(str(exc)) from exc + + async def send(self, obj: dict[str, Any]) -> None: + data = _wire.encode(obj) + async with self._write_lock: + await asyncio.get_running_loop().run_in_executor(None, win32file.WriteFile, self._handle, data) + + async def aclose(self) -> None: + import contextlib + + with contextlib.suppress(Exception): + win32pipe.DisconnectNamedPipe(self._handle) + with contextlib.suppress(Exception): + win32file.CloseHandle(self._handle) + + +class _PipeServer: + """Accept loop for one broker; creates pipe instances and hands each to the handler.""" + + def __init__(self, name: str, handle_client: ClientHandler, loop: asyncio.AbstractEventLoop) -> None: + self.name = name + self._handle_client = handle_client + self._loop = loop + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, name="ctiso-pipe", daemon=True) + self._first = True + + def start(self) -> None: + self._thread.start() + + def _make_instance(self) -> Any: + sa = _pipe_security_attributes() + open_mode = win32con.PIPE_ACCESS_DUPLEX + if self._first: + open_mode |= win32con.FILE_FLAG_FIRST_PIPE_INSTANCE + self._first = False + pipe_mode = win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_READMODE_BYTE | win32pipe.PIPE_WAIT + pipe_mode |= getattr(win32pipe, "PIPE_REJECT_REMOTE_CLIENTS", 0) + return win32pipe.CreateNamedPipe( + self.name, open_mode, pipe_mode, win32pipe.PIPE_UNLIMITED_INSTANCES, _BUF, _BUF, 0, sa + ) + + def _run(self) -> None: + while not self._stop.is_set(): + try: + handle = self._make_instance() + except Exception: # noqa: BLE001 - can't create pipe; stop + break + try: + win32pipe.ConnectNamedPipe(handle, None) + except pywintypes.error as exc: + # ERROR_PIPE_CONNECTED (535): a client connected before we waited. + if getattr(exc, "winerror", None) != 535: + win32file.CloseHandle(handle) + if self._stop.is_set(): + break + continue + if self._stop.is_set(): + win32file.CloseHandle(handle) + break + import functools + + channel = _PipeChannel(handle) + self._loop.call_soon_threadsafe(functools.partial(self._spawn, channel)) + + def _spawn(self, channel: MessageChannel) -> None: + asyncio.ensure_future(self._handle_client(channel), loop=self._loop) # noqa: RUF006 + + def close(self) -> None: + self._stop.set() + # Unblock a pending ConnectNamedPipe by connecting a throwaway client. + try: + h = win32file.CreateFile( + self.name, win32con.GENERIC_READ | win32con.GENERIC_WRITE, 0, None, win32con.OPEN_EXISTING, 0, None + ) + win32file.CloseHandle(h) + except Exception: # noqa: BLE001 + pass + + +async def start_pipe_listener(handle_client: ClientHandler) -> BrokerListener: + name = r"\\.\pipe\ctiso-" + uuid.uuid4().hex + server = _PipeServer(name, handle_client, asyncio.get_running_loop()) + server.start() + return BrokerListener(endpoint=name, transport="pipe", _server=server, _cleanup=None) diff --git a/src/chuk_tool_processor/execution/isolation/_winproc.py b/src/chuk_tool_processor/execution/isolation/_winproc.py new file mode 100644 index 0000000..d80f924 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/_winproc.py @@ -0,0 +1,235 @@ +# chuk_tool_processor/execution/isolation/_winproc.py +""" +Win32 AppContainer process launch + Job Object (EXPERIMENTAL, Windows-only). + +Launches a child process as an AppContainer (via a STARTUPINFOEX security- +capabilities attribute) inside a Job Object that caps memory / active processes +and kills the tree on close. All Win32 access is through ``ctypes`` and confined +to :func:`launch_appcontainer`, so importing this module is harmless on any OS. + +Verified only via Windows CI; structural first draft. +""" + +from __future__ import annotations + +from typing import Any + +# Win32 constants +_EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +_CREATE_SUSPENDED = 0x00000004 +_CREATE_NO_WINDOW = 0x08000000 +_STARTF_USESTDHANDLES = 0x00000100 +_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009 +_JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008 +_JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100 +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_JobObjectExtendedLimitInformation = 9 +_WAIT_TIMEOUT = 0x00000102 +_GENERIC_WRITE = 0x40000000 +_FILE_SHARE_READ = 0x00000001 +_FILE_SHARE_WRITE = 0x00000002 +_CREATE_ALWAYS = 2 +_INVALID_HANDLE_VALUE = -1 + + +def _quote(arg: str) -> str: + if arg and not any(c in arg for c in ' \t"'): + return arg + return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def launch_appcontainer( + *, + argv: list[str], + sid: Any, + limits: Any, + stdout_path: str, + stderr_path: str, + cwd: str, + _ctypes: Any, +) -> tuple[bool, int]: + """Launch ``argv`` as an AppContainer in a Job Object. Returns (timed_out, exit_code).""" + import ctypes + from ctypes import wintypes + + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + + class SECURITY_CAPABILITIES(ctypes.Structure): + _fields_ = [ + ("AppContainerSid", ctypes.c_void_p), + ("Capabilities", ctypes.c_void_p), + ("CapabilityCount", wintypes.DWORD), + ("Reserved", wintypes.DWORD), + ] + + class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), + ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), + ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), + ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.c_void_p), + ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE), + ] + + class STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)] + + class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wintypes.HANDLE), + ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), + ("dwThreadId", wintypes.DWORD), + ] + + class SECURITY_ATTRIBUTES(ctypes.Structure): + _fields_ = [ + ("nLength", wintypes.DWORD), + ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL), + ] + + class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + (n, ctypes.c_ulonglong) for n in ("ReadOp", "WriteOp", "OtherOp", "ReadXfer", "WriteXfer", "OtherXfer") + ] + + class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + def _inheritable_file(path: str) -> Any: + sa = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), None, True) + h = k32.CreateFileW( + ctypes.c_wchar_p(path), + _GENERIC_WRITE, + _FILE_SHARE_READ | _FILE_SHARE_WRITE, + ctypes.byref(sa), + _CREATE_ALWAYS, + 0, + None, + ) + if h == _INVALID_HANDLE_VALUE: + raise ctypes.WinError(ctypes.get_last_error()) + return h + + handles_to_close: list[Any] = [] + attr_list = None + hjob = None + hout = herr = None + try: + # Build the security-capabilities attribute list. + size = ctypes.c_size_t(0) + k32.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(size)) + buf = (ctypes.c_byte * size.value)() + attr_list = ctypes.cast(buf, ctypes.c_void_p) + if not k32.InitializeProcThreadAttributeList(attr_list, 1, 0, ctypes.byref(size)): + raise ctypes.WinError(ctypes.get_last_error()) + caps = SECURITY_CAPABILITIES(sid, None, 0, 0) + if not k32.UpdateProcThreadAttribute( + attr_list, + 0, + _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + ctypes.byref(caps), + ctypes.sizeof(caps), + None, + None, + ): + raise ctypes.WinError(ctypes.get_last_error()) + + hout = _inheritable_file(stdout_path) + herr = _inheritable_file(stderr_path) + handles_to_close += [hout, herr] + + si = STARTUPINFOEXW() + si.StartupInfo.cb = ctypes.sizeof(STARTUPINFOEXW) + si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES + si.StartupInfo.hStdInput = None + si.StartupInfo.hStdOutput = hout + si.StartupInfo.hStdError = herr + si.lpAttributeList = attr_list + + pi = PROCESS_INFORMATION() + cmdline = " ".join(_quote(a) for a in argv) + flags = _EXTENDED_STARTUPINFO_PRESENT | _CREATE_SUSPENDED | _CREATE_NO_WINDOW + if not k32.CreateProcessW( + None, + ctypes.c_wchar_p(cmdline), + None, + None, + True, + flags, + None, + ctypes.c_wchar_p(cwd), + ctypes.byref(si.StartupInfo), + ctypes.byref(pi), + ): + raise ctypes.WinError(ctypes.get_last_error()) + handles_to_close += [pi.hProcess, pi.hThread] + + # Job object with memory / process-count caps; kill tree on close. + hjob = k32.CreateJobObjectW(None, None) + info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + flags_lim = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if limits.max_processes: + flags_lim |= _JOB_OBJECT_LIMIT_ACTIVE_PROCESS + info.BasicLimitInformation.ActiveProcessLimit = int(limits.max_processes) + if limits.memory_bytes: + flags_lim |= _JOB_OBJECT_LIMIT_PROCESS_MEMORY + info.ProcessMemoryLimit = int(limits.memory_bytes) + info.BasicLimitInformation.LimitFlags = flags_lim + k32.SetInformationJobObject(hjob, _JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info)) + k32.AssignProcessToJobObject(hjob, pi.hProcess) + + k32.ResumeThread(pi.hThread) + + timeout_ms = int(max(1.0, limits.wall_timeout) * 1000) + wait = k32.WaitForSingleObject(pi.hProcess, timeout_ms) + if wait == _WAIT_TIMEOUT: + k32.TerminateJobObject(hjob, 1) + k32.WaitForSingleObject(pi.hProcess, 5000) + return True, 1 + + code = wintypes.DWORD(0) + k32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)) + return False, int(code.value) + finally: + if attr_list is not None: + k32.DeleteProcThreadAttributeList(attr_list) + for h in handles_to_close: + if h: + k32.CloseHandle(h) + if hjob: # closing the job kills any survivors (KILL_ON_JOB_CLOSE) + k32.CloseHandle(hjob) diff --git a/src/chuk_tool_processor/execution/isolation/backend.py b/src/chuk_tool_processor/execution/isolation/backend.py index 6b62d95..53cc873 100644 --- a/src/chuk_tool_processor/execution/isolation/backend.py +++ b/src/chuk_tool_processor/execution/isolation/backend.py @@ -35,21 +35,24 @@ class GuestJob: namespace: str | None = None initial_vars: dict[str, Any] = field(default_factory=dict) - def payload(self, *, socket_path: str) -> dict[str, Any]: + def payload(self, *, endpoint: str, transport: 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). + endpoint: The broker endpoint *as seen from inside the guest* — a unix + socket path (``transport="unix"``) or a named pipe name + (``transport="pipe"``). A backend that remaps paths (e.g. a + container bind mount) passes the guest-side value here. + transport: ``"unix"`` or ``"pipe"``; tells the guest how to connect. """ lim = self.limits return { "code": self.code, "namespace": self.namespace, "token": self.token, - "socket_path": socket_path, + "endpoint": endpoint, + "transport": transport, "initial_vars": self.initial_vars, "limits": { "cpu_timeout": lim.cpu_timeout, @@ -90,15 +93,16 @@ 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: + async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: 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=...)``. + host_endpoint: The broker endpoint on the host (unix socket path or + named pipe name). The backend makes it reachable from inside the + guest (bind mount, shared namespace, pipe ACL, etc.) and tells the + guest via ``job.payload(endpoint=..., transport=...)``. + transport: ``"unix"`` or ``"pipe"`` — the broker's transport kind. """ ... diff --git a/src/chuk_tool_processor/execution/isolation/backends/__init__.py b/src/chuk_tool_processor/execution/isolation/backends/__init__.py index 0641d8b..b78412e 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/__init__.py +++ b/src/chuk_tool_processor/execution/isolation/backends/__init__.py @@ -5,10 +5,12 @@ 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 +from chuk_tool_processor.execution.isolation.backends.windows import WindowsBackend __all__ = [ "LocalProcessBackend", "SeatbeltBackend", "DockerBackend", "BubblewrapBackend", + "WindowsBackend", ] diff --git a/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py index 160135a..391193f 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py +++ b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py @@ -39,10 +39,10 @@ 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 + host_endpoint: str # broker endpoint on the host (unix socket path here) 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 + endpoint_guest: str # broker endpoint as the guest sees it class SubprocessBackend: @@ -64,14 +64,14 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: # noqa: A """Sandbox launcher prefix, e.g. ['sandbox-exec', '-p', profile].""" return [] - def _guest_ctx(self, workdir: str, host_socket_path: str) -> _LaunchCtx: + def _guest_ctx(self, workdir: str, host_endpoint: str) -> _LaunchCtx: """Map host paths to guest-visible paths (identity for same-fs backends).""" return _LaunchCtx( workdir=workdir, - host_socket_path=host_socket_path, + host_endpoint=host_endpoint, bootstrap_guest=os.path.join(workdir, "guest_bootstrap.py"), job_guest=os.path.join(workdir, "job.json"), - socket_guest=host_socket_path, + endpoint_guest=host_endpoint, ) def _apply_rlimits_in_preexec(self) -> bool: @@ -84,17 +84,17 @@ def _extra_env(self) -> dict[str, str]: # -- main flow --------------------------------------------------------- # - async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "unix") -> 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) + ctx = self._guest_ctx(workdir, host_endpoint) import json - payload = job.payload(socket_path=ctx.socket_guest) + payload = job.payload(endpoint=ctx.endpoint_guest, transport=transport) with open(os.path.join(workdir, "job.json"), "w", encoding="utf-8") as fh: json.dump(payload, fh) diff --git a/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py b/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py index c90e97c..08fbe8d 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py +++ b/src/chuk_tool_processor/execution/isolation/backends/bubblewrap.py @@ -62,8 +62,8 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: ctx.workdir, ctx.workdir, "--bind", - os.path.dirname(ctx.socket_guest), - os.path.dirname(ctx.socket_guest), + os.path.dirname(ctx.endpoint_guest), + os.path.dirname(ctx.endpoint_guest), "--chdir", "/", "--", diff --git a/src/chuk_tool_processor/execution/isolation/backends/docker.py b/src/chuk_tool_processor/execution/isolation/backends/docker.py index 54a117c..1cf4df3 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/docker.py +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -45,21 +45,21 @@ def _python_exe(self) -> str: 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) + def _guest_ctx(self, workdir: str, host_endpoint: str) -> _LaunchCtx: + socket_name = os.path.basename(host_endpoint) return _LaunchCtx( workdir=workdir, - host_socket_path=host_socket_path, + host_endpoint=host_endpoint, bootstrap_guest=f"{_GUEST_MOUNT}/guest_bootstrap.py", job_guest=f"{_GUEST_MOUNT}/job.json", - socket_guest=f"{_SOCK_MOUNT}/{socket_name}", + endpoint_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) + socket_dir = os.path.dirname(ctx.host_endpoint) lim = job.limits argv = [ self.docker_bin, @@ -103,7 +103,7 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: ] return argv - async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutcome: + async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "unix") -> 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() @@ -113,7 +113,7 @@ async def run_guest(self, job: GuestJob, *, host_socket_path: str) -> GuestOutco # 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) + return await super().run_guest(job, host_endpoint=host_endpoint, transport=transport) finally: await self._force_remove(self._container_name(job)) diff --git a/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py index 90c5945..c8dee61 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py +++ b/src/chuk_tool_processor/execution/isolation/backends/seatbelt.py @@ -87,7 +87,7 @@ def _extra_env(self) -> dict[str, str]: return {"PYTHONDONTWRITEBYTECODE": "1"} def _profile(self, ctx: _LaunchCtx, job: GuestJob) -> str: # noqa: ARG002 - uniform hook signature - socket_dir = os.path.dirname(ctx.socket_guest) + socket_dir = os.path.dirname(ctx.endpoint_guest) write_roots = [ctx.workdir, socket_dir, "/private/tmp", "/tmp"] lines = [ diff --git a/src/chuk_tool_processor/execution/isolation/backends/windows.py b/src/chuk_tool_processor/execution/isolation/backends/windows.py new file mode 100644 index 0000000..0cf58c6 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/backends/windows.py @@ -0,0 +1,179 @@ +# chuk_tool_processor/execution/isolation/backends/windows.py +""" +Windows AppContainer backend (EXPERIMENTAL). + +The Windows analogue of the macOS Seatbelt backend. It launches the guest as an +**AppContainer** process (capability-restricted, low integrity by construction), +placed inside a **Job Object** that caps memory / active processes and kills the +whole tree on close. With no capabilities granted, the AppContainer has no +network and no access to the user's files; the guest reaches the host only via +the broker **named pipe**, whose ACL grants ``ALL APPLICATION PACKAGES``. + + macOS Seatbelt profile ≈ Windows AppContainer + Job Object (+ low integrity) + +.. warning:: + **Experimental and verified only via Windows CI.** This module uses the + Win32 AppContainer / Job Object / CreateProcess APIs through ``ctypes`` and + is import-safe on non-Windows (``is_available()`` returns False). The + staging dir is granted to ``ALL APPLICATION PACKAGES`` (via ``icacls``) so + the AppContainer can read the bootstrap and write its output; everything + else is denied by the container. Details (exact capability set, stdout + capture) may need adjustment as CI exercises it. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from typing import Any + +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.windows") + +_IS_WINDOWS = sys.platform == "win32" + +_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") + +# ALL APPLICATION PACKAGES — every AppContainer can be granted via this SID. +_ALL_APP_PACKAGES = "*S-1-15-2-1" + +_MAX_OUTPUT = 64 * 1024 + + +class WindowsBackend: + """AppContainer + Job Object isolated guest (experimental).""" + + name = "windows" + provides_isolation = True + + def is_available(self) -> bool: + if not _IS_WINDOWS: + return False + try: + import ctypes + + # AppContainer profile APIs live in userenv.dll. + ctypes.WinDLL("userenv") + ctypes.WinDLL("kernel32") + return True + except Exception: # noqa: BLE001 + return False + + async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "pipe") -> GuestOutcome: + # Deferred so this module imports cleanly on non-Windows. + import asyncio + + return await asyncio.get_running_loop().run_in_executor(None, self._run_blocking, job, host_endpoint, transport) + + # -- blocking Win32 launch (runs in an executor thread) ---------------- # + + def _run_blocking(self, job: GuestJob, host_endpoint: str, transport: str) -> GuestOutcome: + import json + + workdir = tempfile.mkdtemp(prefix="ctiso-") + container_name = "ctiso-" + job.token[:24] + stdout_path = os.path.join(workdir, "stdout.txt") + stderr_path = os.path.join(workdir, "stderr.txt") + sid = None + try: + shutil.copy2(_BOOTSTRAP_SRC, os.path.join(workdir, "guest_bootstrap.py")) + shutil.copy2(_WIRE_SRC, os.path.join(workdir, "_wire.py")) + # Pipe names are machine-global, so the guest endpoint == host endpoint. + payload = job.payload(endpoint=host_endpoint, transport=transport) + with open(os.path.join(workdir, "job.json"), "w", encoding="utf-8") as fh: + json.dump(payload, fh) + + # Let the AppContainer read the staging dir and write its output there. + self._grant_app_packages(workdir) + + sid = self._create_app_container(container_name) + timed_out, exit_code = self._launch(job, workdir, sid, stdout_path, stderr_path) + + return GuestOutcome( + exit_code=exit_code, + stdout=_read_capped(stdout_path), + stderr=_read_capped(stderr_path), + timed_out=timed_out, + ) + finally: + if sid is not None: + self._delete_app_container(container_name) + shutil.rmtree(workdir, ignore_errors=True) + + def _grant_app_packages(self, path: str) -> None: + # icacls is the pragmatic way to add an inheritable ACE for AppContainers. + subprocess.run( + ["icacls", path, "/grant", f"{_ALL_APP_PACKAGES}:(OI)(CI)(F)", "/T", "/Q"], + capture_output=True, + check=False, + ) + + # -- AppContainer profile ---------------------------------------------- # + + def _create_app_container(self, name: str) -> Any: + import ctypes + from ctypes import wintypes + + userenv = ctypes.WinDLL("userenv") + psid = ctypes.c_void_p() + # CreateAppContainerProfile(name, displayName, desc, capabilities, count, *sid) + hr = userenv.CreateAppContainerProfile( + ctypes.c_wchar_p(name), + ctypes.c_wchar_p(name), + ctypes.c_wchar_p("chuk-tool-processor isolated guest"), + None, + wintypes.DWORD(0), + ctypes.byref(psid), + ) + if hr != 0: + # 0x800700B7 = already exists -> derive the SID instead. + derive = userenv.DeriveAppContainerSidFromAppContainerName + sid2 = ctypes.c_void_p() + hr2 = derive(ctypes.c_wchar_p(name), ctypes.byref(sid2)) + if hr2 != 0: + raise OSError(f"CreateAppContainerProfile failed (0x{hr & 0xFFFFFFFF:08x})") + return sid2 + return psid + + def _delete_app_container(self, name: str) -> None: + import contextlib + import ctypes + + with contextlib.suppress(Exception): + ctypes.WinDLL("userenv").DeleteAppContainerProfile(ctypes.c_wchar_p(name)) + + # -- process launch + job object --------------------------------------- # + + def _launch(self, job: GuestJob, workdir: str, sid: Any, stdout_path: str, stderr_path: str) -> tuple[bool, int]: + import ctypes + + from chuk_tool_processor.execution.isolation import _winproc + + argv = [sys.executable, os.path.join(workdir, "guest_bootstrap.py"), os.path.join(workdir, "job.json")] + return _winproc.launch_appcontainer( + argv=argv, + sid=sid, + limits=job.limits, + stdout_path=stdout_path, + stderr_path=stderr_path, + cwd=workdir, + _ctypes=ctypes, + ) + + +def _read_capped(path: str) -> str: + try: + with open(path, "rb") as fh: + data = fh.read(_MAX_OUTPUT + 1) + except OSError: + return "" + if len(data) > _MAX_OUTPUT: + data = data[:_MAX_OUTPUT] + b"\n...[truncated]" + return data.decode("utf-8", errors="replace") diff --git a/src/chuk_tool_processor/execution/isolation/broker.py b/src/chuk_tool_processor/execution/isolation/broker.py index 3aaf3ab..50ea2ef 100644 --- a/src/chuk_tool_processor/execution/isolation/broker.py +++ b/src/chuk_tool_processor/execution/isolation/broker.py @@ -21,13 +21,10 @@ 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.execution.isolation.transport import BrokerListener, MessageChannel, start_listener from chuk_tool_processor.logging import get_logger from chuk_tool_processor.registry.interface import ToolRegistryInterface @@ -74,47 +71,38 @@ def __init__( 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._listener: BrokerListener | 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 + """Start the platform broker server. Returns the endpoint the guest connects to.""" + self._listener = await start_listener(self._handle_client) + logger.debug("Tool broker listening (%s) at %s", self._listener.transport, self._listener.endpoint) + return self._listener.endpoint 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 + """Stop the server and clean up its endpoint.""" + if self._listener is not None: + await self._listener.aclose() + self._listener = None if not self._result_future.done(): self._result_future.cancel() # -- accessors --------------------------------------------------------- # @property - def socket_path(self) -> str | None: - return self._socket_path + def endpoint(self) -> str | None: + return self._listener.endpoint if self._listener else None + + @property + def transport(self) -> str | None: + return self._listener.transport if self._listener else None @property def tool_calls(self) -> int: @@ -131,52 +119,50 @@ def has_result(self) -> bool: # -- connection handling ----------------------------------------------- # - async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + async def _handle_client(self, channel: MessageChannel) -> None: try: - hello = await _wire.recv(reader) + hello = await channel.recv() 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"}) + await self._reply(channel, {"id": hello.get("id"), "ok": False, "error": "unauthorized"}) return - await self._reply(writer, {"id": hello.get("id"), "ok": True}) + await self._reply(channel, {"id": hello.get("id"), "ok": True}) while True: try: - msg = await _wire.recv(reader) - except (EOFError, asyncio.IncompleteReadError): + msg = await channel.recv() + except (EOFError, asyncio.IncompleteReadError, ConnectionError): break # Each request handled concurrently so guest asyncio.gather works. - asyncio.create_task(self._dispatch(msg, writer)) + asyncio.create_task(self._dispatch(msg, channel)) 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() + await channel.aclose() - async def _dispatch(self, msg: dict[str, Any], writer: asyncio.StreamWriter) -> None: + async def _dispatch(self, msg: dict[str, Any], channel: MessageChannel) -> 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()}) + await self._reply(channel, {"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}) + await self._reply(channel, {"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}) + await self._reply(channel, {"id": msg_id, "ok": True}) else: - await self._reply(writer, {"id": msg_id, "ok": False, "error": f"unknown method: {method}"}) + await self._reply(channel, {"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)}) + await self._reply(channel, {"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}"}) + await self._reply(channel, {"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) + async def _reply(self, channel: MessageChannel, obj: dict[str, Any]) -> None: + with contextlib.suppress(Exception): + await channel.send(obj) # -- capabilities ------------------------------------------------------ # diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index 6bde94f..56e6fe0 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -135,8 +135,36 @@ async def _run_user_code(code: str, exec_globals: dict): return local_scope.get("__result__") +async def _connect(job: dict): + """Connect to the broker endpoint per the job's transport.""" + transport = job.get("transport", "unix") + endpoint = job.get("endpoint") or job.get("socket_path") + if transport == "pipe": + return await _open_pipe(endpoint) + return await asyncio.open_unix_connection(endpoint) + + +async def _open_pipe(name: str): + """Windows named-pipe client -> (StreamReader, StreamWriter). Retries while busy.""" + import time + + loop = asyncio.get_running_loop() + last = None + for _ in range(50): # ~5s of retries for pipe-busy / not-yet-listening + try: + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + transport, _proto = await loop.create_pipe_connection(lambda p=protocol: p, name) # type: ignore[attr-defined] # noqa: B023 + writer = asyncio.StreamWriter(transport, protocol, reader, loop) + return reader, writer + except (FileNotFoundError, OSError) as exc: # pipe not ready / all instances busy + last = exc + time.sleep(0.1) + raise last or ConnectionError(f"could not connect to pipe {name}") + + async def _main(job: dict) -> int: - reader, writer = await asyncio.open_unix_connection(job["socket_path"]) + reader, writer = await _connect(job) client = _RpcClient(reader, writer) client.start() await client.hello(job["token"]) diff --git a/src/chuk_tool_processor/execution/isolation/runner.py b/src/chuk_tool_processor/execution/isolation/runner.py index 0f1a654..a7931eb 100644 --- a/src/chuk_tool_processor/execution/isolation/runner.py +++ b/src/chuk_tool_processor/execution/isolation/runner.py @@ -93,7 +93,7 @@ async def run( namespace=ns, allowed_tools=self.allowed_tools, ) - socket_path = await broker.start() + endpoint = await broker.start() try: job = GuestJob( code=code, @@ -103,7 +103,7 @@ async def run( initial_vars=initial_vars or {}, ) started = time.monotonic() - outcome = await self.backend.run_guest(job, host_socket_path=socket_path) + outcome = await self.backend.run_guest(job, host_endpoint=endpoint, transport=broker.transport or "unix") duration = time.monotonic() - started return self._assemble(outcome, broker, duration) finally: diff --git a/src/chuk_tool_processor/execution/isolation/transport.py b/src/chuk_tool_processor/execution/isolation/transport.py new file mode 100644 index 0000000..4f26d63 --- /dev/null +++ b/src/chuk_tool_processor/execution/isolation/transport.py @@ -0,0 +1,128 @@ +# chuk_tool_processor/execution/isolation/transport.py +""" +Pluggable broker transport. + +The host tool-broker needs a local, streaming, authenticated channel to the +guest. POSIX uses a unix-domain socket in a 0700 dir. Windows has no unix +sockets, and (for the AppContainer backend) TCP loopback is blocked without a +network-capability hole — so Windows uses a **named pipe** whose security +descriptor grants ``ALL APPLICATION PACKAGES`` and a low-integrity label, which +an AppContainer child can reach without any network access. + +Both transports present the same interface to the broker: a callback receiving +``(asyncio.StreamReader, asyncio.StreamWriter)`` per connection, plus a string +``endpoint`` the guest connects to. The guest side lives in ``guest_bootstrap`` +(dependency-free); this module is host-only. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import shutil +import tempfile +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from chuk_tool_processor.execution.isolation import _wire + +IS_WINDOWS = os.name == "nt" + + +class MessageChannel: + """A duplex, framed-JSON message channel to one guest connection. + + Abstracts over the concrete transport so the broker never touches sockets or + pipes directly. The unix transport backs this with asyncio streams; the + Windows pipe transport backs it with blocking pipe I/O in a thread. + """ + + async def recv(self) -> dict[str, Any]: + raise NotImplementedError + + async def send(self, obj: dict[str, Any]) -> None: + raise NotImplementedError + + async def aclose(self) -> None: + raise NotImplementedError + + +class StreamChannel(MessageChannel): + """MessageChannel over an asyncio (reader, writer) pair using ``_wire`` framing.""" + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self._reader = reader + self._writer = writer + self._write_lock = asyncio.Lock() + + async def recv(self) -> dict[str, Any]: + return await _wire.recv(self._reader) + + async def send(self, obj: dict[str, Any]) -> None: + async with self._write_lock: + await _wire.send(self._writer, obj) + + async def aclose(self) -> None: + with contextlib.suppress(Exception): + self._writer.close() + + +ClientHandler = Callable[[MessageChannel], Awaitable[None]] + + +def default_transport() -> str: + """The transport kind for this platform: ``"pipe"`` on Windows, else ``"unix"``.""" + return "pipe" if IS_WINDOWS else "unix" + + +@dataclass +class BrokerListener: + """A started broker server plus the endpoint the guest connects to.""" + + endpoint: str + transport: str + _server: Any + _cleanup: Callable[[], None] | None = None + + async def aclose(self) -> None: + server = self._server + with contextlib.suppress(Exception): # teardown must not raise + close = getattr(server, "close", None) + if close: + close() + wait_closed = getattr(server, "wait_closed", None) + if wait_closed: + await wait_closed() + if self._cleanup: + with contextlib.suppress(Exception): + self._cleanup() + + +async def start_listener(handle_client: ClientHandler) -> BrokerListener: + """Start the platform-appropriate broker server and return its listener.""" + if IS_WINDOWS: + # Imported lazily: the module uses Windows-only APIs. + from chuk_tool_processor.execution.isolation import _winpipe + + return await _winpipe.start_pipe_listener(handle_client) + return await _start_unix_listener(handle_client) + + +async def _start_unix_listener(handle_client: ClientHandler) -> BrokerListener: + sock_dir = tempfile.mkdtemp(prefix="cticode-") + os.chmod(sock_dir, 0o700) + path = os.path.join(sock_dir, "broker.sock") + + async def _on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await handle_client(StreamChannel(reader, writer)) + + server = await asyncio.start_unix_server(_on_client, path=path) + os.chmod(path, 0o600) + return BrokerListener( + endpoint=path, + transport="unix", + _server=server, + _cleanup=lambda: shutil.rmtree(sock_dir, ignore_errors=True), + ) diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py index 5ed24f1..0248273 100644 --- a/tests/execution/isolation/test_backends.py +++ b/tests/execution/isolation/test_backends.py @@ -25,11 +25,10 @@ IsolatedCodeRunner, IsolationLimits, SeatbeltBackend, + WindowsBackend, ) 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") +from chuk_tool_processor.execution.isolation.transport import default_transport # 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. @@ -48,7 +47,7 @@ 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" + assert ctx.endpoint_guest == "/ctsock/broker.sock" def test_wrapper_argv_hardening(self): b = DockerBackend(image="python:3.12-slim") @@ -97,8 +96,9 @@ def test_allow_network_keeps_net(self): # --------------------------------------------------------------------------- # -# Seatbelt denylist configuration (pure profile construction; runs on any OS) +# Seatbelt denylist configuration (pure profile construction; POSIX paths) # --------------------------------------------------------------------------- # +@pytest.mark.skipif(sys.platform == "win32", reason="Seatbelt profile uses POSIX path formatting") class TestSeatbeltDenyReadPaths: def _profile(self, backend: SeatbeltBackend) -> str: ctx = backend._guest_ctx("/work", "/host/sock/broker.sock") @@ -120,6 +120,50 @@ def test_deny_read_paths_overrides_defaults(self): assert os.path.realpath(os.path.expanduser("~/.ssh")) not in prof # defaults replaced +# --------------------------------------------------------------------------- # +# Windows (experimental) — platform gating + transport selection +# --------------------------------------------------------------------------- # +class TestWindows: + def test_default_transport_matches_platform(self): + assert default_transport() == ("pipe" if sys.platform == "win32" else "unix") + + def test_unavailable_off_windows(self): + if sys.platform != "win32": + assert WindowsBackend().is_available() is False + + +_WIN_BACKEND_OPTIN = WindowsBackend().is_available() and os.environ.get("CTP_TEST_ISOLATION_WINDOWS") == "1" + + +@pytest.mark.skipif( + not _WIN_BACKEND_OPTIN, + reason="experimental: run on Windows with CTP_TEST_ISOLATION_WINDOWS=1", +) +class TestWindowsIntegration: + @pytest.mark.asyncio + async def test_add_loop(self): + runner = IsolatedCodeRunner( + WindowsBackend(), + 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( + WindowsBackend(), + 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 + + # --------------------------------------------------------------------------- # # Integration (gated) — Docker # --------------------------------------------------------------------------- # diff --git a/tests/execution/isolation/test_runner.py b/tests/execution/isolation/test_runner.py index 4ba6837..d97e770 100644 --- a/tests/execution/isolation/test_runner.py +++ b/tests/execution/isolation/test_runner.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import os import sys from dataclasses import dataclass @@ -25,9 +26,14 @@ _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") +# On Windows the broker uses the experimental named-pipe transport (needs +# pywin32). Skip broker-executing tests there unless explicitly opted in — the +# dedicated isolation CI job sets this. POSIX always runs them. +_WIN_ISOLATION_OPTIN = os.environ.get("CTP_TEST_ISOLATION_WINDOWS") == "1" +skip_windows_broker = pytest.mark.skipif( + sys.platform == "win32" and not _WIN_ISOLATION_OPTIN, + reason="Windows pipe transport is experimental; set CTP_TEST_ISOLATION_WINDOWS=1 to run", +) # --------------------------------------------------------------------------- # @@ -130,8 +136,9 @@ def test_non_isolating_backend_allowed_with_optin(self): # --------------------------------------------------------------------------- # -# Core execution via the Local backend (works everywhere) +# Core execution via the Local backend (works everywhere; Windows via pipe) # --------------------------------------------------------------------------- # +@skip_windows_broker class TestLocalExecution: @pytest.mark.asyncio async def test_add_loop(self, registry): diff --git a/uv.lock b/uv.lock index aaac9d3..1eda616 100644 --- a/uv.lock +++ b/uv.lock @@ -232,6 +232,9 @@ fast-json = [ full = [ { name = "orjson" }, ] +isolation-windows = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] redis = [ { name = "redis", extra = ["hiredis"] }, ] @@ -270,10 +273,11 @@ requires-dist = [ { name = "psutil", specifier = ">=7.0.0" }, { name = "pydantic", specifier = ">=2.11.3" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'isolation-windows'", specifier = ">=306" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'all'", specifier = ">=8,<9" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=8,<9" }, ] -provides-extras = ["fast-json", "redis", "full", "all"] +provides-extras = ["fast-json", "redis", "isolation-windows", "full", "all"] [package.metadata.requires-dev] dev = [ @@ -1575,6 +1579,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 0c755c24e46bce7249e61cf338bd6ddf62c7f733 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 12:23:21 +0100 Subject: [PATCH 02/12] fix(windows): gate Docker to Linux, grant AppContainer read on interpreter, pipe SD fallback CI iteration for the Windows isolation job: - Docker integration is Linux-only (Windows daemon can't run the linux image). - WindowsBackend grants ALL APPLICATION PACKAGES read+execute on the interpreter dirs so the AppContainer python can read pyvenv.cfg + stdlib. - Named-pipe server falls back to the default security descriptor if the custom AppContainer SD can't be built, so the pipe still comes up for the local guest. Signed-off-by: chris hay --- .../execution/isolation/_winpipe.py | 10 +++++++++- .../execution/isolation/backends/windows.py | 13 +++++++++++++ tests/execution/isolation/test_backends.py | 6 ++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/chuk_tool_processor/execution/isolation/_winpipe.py b/src/chuk_tool_processor/execution/isolation/_winpipe.py index 6ddcb9e..98698a2 100644 --- a/src/chuk_tool_processor/execution/isolation/_winpipe.py +++ b/src/chuk_tool_processor/execution/isolation/_winpipe.py @@ -116,12 +116,20 @@ def __init__(self, name: str, handle_client: ClientHandler, loop: asyncio.Abstra self._stop = threading.Event() self._thread = threading.Thread(target=self._run, name="ctiso-pipe", daemon=True) self._first = True + # Build the AppContainer-accessible security descriptor once. If that + # fails, fall back to the default SD so the pipe still comes up — the + # local (non-AppContainer) guest connects as the current user regardless; + # only the AppContainer backend needs the custom grant. + try: + self._sa: Any = _pipe_security_attributes() + except Exception: # noqa: BLE001 + self._sa = None def start(self) -> None: self._thread.start() def _make_instance(self) -> Any: - sa = _pipe_security_attributes() + sa = self._sa open_mode = win32con.PIPE_ACCESS_DUPLEX if self._first: open_mode |= win32con.FILE_FLAG_FIRST_PIPE_INSTANCE diff --git a/src/chuk_tool_processor/execution/isolation/backends/windows.py b/src/chuk_tool_processor/execution/isolation/backends/windows.py index 0cf58c6..a77a7cf 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/windows.py +++ b/src/chuk_tool_processor/execution/isolation/backends/windows.py @@ -92,6 +92,11 @@ def _run_blocking(self, job: GuestJob, host_endpoint: str, transport: str) -> Gu # Let the AppContainer read the staging dir and write its output there. self._grant_app_packages(workdir) + # ...and read the interpreter (venv + base install) so Python can + # start (it must read pyvenv.cfg + the stdlib). NB: this modifies the + # interpreter dir's ACL persistently — fine for CI/throwaway envs. + for prefix in {os.path.realpath(sys.base_prefix), os.path.realpath(sys.prefix)}: + self._grant_read(prefix) sid = self._create_app_container(container_name) timed_out, exit_code = self._launch(job, workdir, sid, stdout_path, stderr_path) @@ -115,6 +120,14 @@ def _grant_app_packages(self, path: str) -> None: check=False, ) + def _grant_read(self, path: str) -> None: + # Read+execute (not full) for read-only trees like the interpreter. + subprocess.run( + ["icacls", path, "/grant", f"{_ALL_APP_PACKAGES}:(OI)(CI)(RX)", "/T", "/Q"], + capture_output=True, + check=False, + ) + # -- AppContainer profile ---------------------------------------------- # def _create_app_container(self, name: str) -> Any: diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py index 0248273..0cb7f7f 100644 --- a/tests/execution/isolation/test_backends.py +++ b/tests/execution/isolation/test_backends.py @@ -168,7 +168,9 @@ async def test_network_blocked(self): # Integration (gated) — Docker # --------------------------------------------------------------------------- # def _docker_up() -> bool: - if shutil.which("docker") is None: + # DockerBackend targets a Linux Docker host (Linux container + bind-mounted + # unix socket); a Windows-container daemon can't run the linux python image. + if not sys.platform.startswith("linux") or shutil.which("docker") is None: return False try: return subprocess.run(["docker", "info"], capture_output=True, timeout=10).returncode == 0 @@ -178,7 +180,7 @@ def _docker_up() -> bool: @pytest.mark.skipif( not (_RUN_INTEGRATION and _docker_up()), - reason="set CTP_TEST_ISOLATION_INTEGRATION=1 with a running Docker daemon", + reason="set CTP_TEST_ISOLATION_INTEGRATION=1 on Linux with a running Docker daemon", ) class TestDockerIntegration: @pytest.mark.asyncio From b78d4e02ad709ebe9cdc6bae4d08cae05b555152 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 12:28:04 +0100 Subject: [PATCH 03/12] fix(windows): hardcode named-pipe Win32 constants (win32con may not define them) PIPE_ACCESS_DUPLEX / FILE_FLAG_FIRST_PIPE_INSTANCE aren't reliably exposed by win32con; an AttributeError was killing the pipe-server thread, so the guest never found the pipe (FileNotFoundError). Signed-off-by: chris hay --- src/chuk_tool_processor/execution/isolation/_winpipe.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/chuk_tool_processor/execution/isolation/_winpipe.py b/src/chuk_tool_processor/execution/isolation/_winpipe.py index 98698a2..2b10d1a 100644 --- a/src/chuk_tool_processor/execution/isolation/_winpipe.py +++ b/src/chuk_tool_processor/execution/isolation/_winpipe.py @@ -41,6 +41,10 @@ _LEN = struct.Struct(">I") _BUF = 65536 +# Win32 constants (not reliably exposed by win32con across pywin32 versions). +_PIPE_ACCESS_DUPLEX = 0x00000003 +_FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000 + def _pipe_security_attributes() -> Any: """SECURITY_ATTRIBUTES granting the user + ALL APPLICATION PACKAGES at low IL.""" @@ -130,9 +134,9 @@ def start(self) -> None: def _make_instance(self) -> Any: sa = self._sa - open_mode = win32con.PIPE_ACCESS_DUPLEX + open_mode = _PIPE_ACCESS_DUPLEX if self._first: - open_mode |= win32con.FILE_FLAG_FIRST_PIPE_INSTANCE + open_mode |= _FILE_FLAG_FIRST_PIPE_INSTANCE self._first = False pipe_mode = win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_READMODE_BYTE | win32pipe.PIPE_WAIT pipe_mode |= getattr(win32pipe, "PIPE_REJECT_REMOTE_CLIENTS", 0) From 3679fa01992404265907e2c32e82f9049cdd609d Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 13:40:38 +0100 Subject: [PATCH 04/12] debug(windows): add guest connection checkpoints (CTP_GUEST_DEBUG) to locate pipe hang Signed-off-by: chris hay --- .github/workflows/isolation.yml | 1 + .../execution/isolation/guest_bootstrap.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml index 3a95347..cf1a355 100644 --- a/.github/workflows/isolation.yml +++ b/.github/workflows/isolation.yml @@ -32,6 +32,7 @@ jobs: # 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 - name: Install uv diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index 56e6fe0..c27ecca 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -19,6 +19,7 @@ import asyncio import json +import os import sys import _wire # sibling copy placed next to this file by the backend @@ -163,13 +164,23 @@ async def _open_pipe(name: str): raise last or ConnectionError(f"could not connect to pipe {name}") +def _dbg(msg: str) -> None: + if os.environ.get("CTP_GUEST_DEBUG") == "1": + print(f"[guest] {msg}", file=sys.stderr, flush=True) + + async def _main(job: dict) -> int: + _dbg(f"connecting transport={job.get('transport')} endpoint={job.get('endpoint')}") reader, writer = await _connect(job) + _dbg("connected; starting rpc client") client = _RpcClient(reader, writer) client.start() + _dbg("sending hello") await client.hello(job["token"]) + _dbg("hello ack; requesting list_tools") tools = await client.request("list_tools", {}) + _dbg(f"got {len(tools)} tools; running user code") 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"]) From 8516e3e37ef61b18105452b3e2bdb38c0a792dc9 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 14:31:30 +0100 Subject: [PATCH 05/12] debug(windows): host-side broker checkpoints (CTP_GUEST_DEBUG) to isolate pipe hang Signed-off-by: chris hay --- .../execution/isolation/broker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/chuk_tool_processor/execution/isolation/broker.py b/src/chuk_tool_processor/execution/isolation/broker.py index 50ea2ef..c8487b5 100644 --- a/src/chuk_tool_processor/execution/isolation/broker.py +++ b/src/chuk_tool_processor/execution/isolation/broker.py @@ -31,6 +31,15 @@ logger = get_logger("chuk_tool_processor.execution.isolation.broker") +def _dbg(msg: str) -> None: + import os + + if os.environ.get("CTP_GUEST_DEBUG") == "1": + import sys + + print(f"[host] {msg}", file=sys.stderr, flush=True) + + def _to_jsonable(obj: Any) -> Any: """Best-effort conversion of a tool result into JSON-serialisable data.""" try: @@ -133,6 +142,7 @@ async def _handle_client(self, channel: MessageChannel) -> None: msg = await channel.recv() except (EOFError, asyncio.IncompleteReadError, ConnectionError): break + _dbg(f"recv {msg.get('method')} id={msg.get('id')}") # Each request handled concurrently so guest asyncio.gather works. asyncio.create_task(self._dispatch(msg, channel)) except Exception as exc: # noqa: BLE001 - broker must never crash the host @@ -143,9 +153,11 @@ async def _handle_client(self, channel: MessageChannel) -> None: async def _dispatch(self, msg: dict[str, Any], channel: MessageChannel) -> None: method = msg.get("method") msg_id = msg.get("id") + _dbg(f"dispatch {method} id={msg_id}") try: if method == "list_tools": await self._reply(channel, {"id": msg_id, "ok": True, "value": await self._list_tools()}) + _dbg(f"replied {method} id={msg_id}") elif method == "call_tool": value = await self._call_tool(msg.get("params") or {}) await self._reply(channel, {"id": msg_id, "ok": True, "value": value}) From 3325316005d64eedbaf81c9917a1a1732648ad83 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 15:10:11 +0100 Subject: [PATCH 06/12] fix(windows): guest uses blocking win32 pipe I/O (Proactor pipe reader drops frames) The host received list_tools and sent the reply, but the guest's asyncio Proactor named-pipe reader stopped delivering frames after the hello ack, so the guest hung. Switch the guest pipe transport to the same blocking win32 ReadFile/WriteFile (in the executor) the host uses, via a channel abstraction (_StreamChannel for unix, _PipeChannel for the Windows pipe). Signed-off-by: chris hay --- .../execution/isolation/guest_bootstrap.py | 116 ++++++++++++++---- 1 file changed, 93 insertions(+), 23 deletions(-) diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index c27ecca..ae4ed3a 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio +import contextlib import json import os import sys @@ -73,9 +74,8 @@ def _jsonable(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 + def __init__(self, channel): + self._channel = channel self._next_id = 0 self._pending: dict[int, asyncio.Future] = {} self._reader_task: asyncio.Task | None = None @@ -86,11 +86,11 @@ def start(self) -> None: async def _read_loop(self) -> None: try: while True: - msg = await _wire.recv(self._reader) + msg = await self._channel.recv() fut = self._pending.pop(msg.get("id"), None) if fut and not fut.done(): fut.set_result(msg) - except (EOFError, asyncio.IncompleteReadError): + except (EOFError, asyncio.IncompleteReadError, ConnectionError, OSError): for fut in self._pending.values(): if not fut.done(): fut.set_exception(ConnectionError("broker closed the connection")) @@ -101,7 +101,7 @@ async def _roundtrip(self, frame: dict): frame["id"] = msg_id fut = asyncio.get_running_loop().create_future() self._pending[msg_id] = fut - await _wire.send(self._writer, frame) + await self._channel.send(frame) reply = await fut if not reply.get("ok"): raise RuntimeError(reply.get("error") or "broker rejected request") @@ -136,32 +136,102 @@ async def _run_user_code(code: str, exec_globals: dict): return local_scope.get("__result__") +class _StreamChannel: + """Framed-JSON channel over an asyncio (reader, writer) pair (unix socket).""" + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + self._reader = reader + self._writer = writer + + async def recv(self) -> dict: + return await _wire.recv(self._reader) + + async def send(self, obj: dict) -> None: + await _wire.send(self._writer, obj) + + def close(self) -> None: + with contextlib.suppress(Exception): + self._writer.close() + + +class _PipeChannel: + """Framed-JSON channel over a connected Windows named-pipe handle. + + Uses blocking win32 ReadFile/WriteFile in the default executor — the asyncio + Proactor pipe transport drops frames after the first, so we mirror the host's + blocking approach instead. + """ + + def __init__(self, handle): + self._handle = handle + + def _read_exact(self, n: int) -> bytes: + import win32file + + chunks = bytearray() + while len(chunks) < n: + _hr, data = win32file.ReadFile(self._handle, n - len(chunks)) + if not data: + raise ConnectionError("pipe closed") + chunks.extend(data) + return bytes(chunks) + + def _recv_blocking(self) -> dict: + import struct + + (length,) = struct.unpack(">I", self._read_exact(4)) + return json.loads(self._read_exact(length).decode("utf-8")) + + async def recv(self) -> dict: + return await asyncio.get_running_loop().run_in_executor(None, self._recv_blocking) + + async def send(self, obj: dict) -> None: + import win32file + + data = _wire.encode(obj) + await asyncio.get_running_loop().run_in_executor(None, win32file.WriteFile, self._handle, data) + + def close(self) -> None: + with contextlib.suppress(Exception): + import win32file + + win32file.CloseHandle(self._handle) + + async def _connect(job: dict): - """Connect to the broker endpoint per the job's transport.""" + """Connect to the broker endpoint per the job's transport. Returns a channel.""" transport = job.get("transport", "unix") endpoint = job.get("endpoint") or job.get("socket_path") if transport == "pipe": - return await _open_pipe(endpoint) - return await asyncio.open_unix_connection(endpoint) + return _PipeChannel(_open_pipe_handle(endpoint)) + reader, writer = await asyncio.open_unix_connection(endpoint) + return _StreamChannel(reader, writer) -async def _open_pipe(name: str): - """Windows named-pipe client -> (StreamReader, StreamWriter). Retries while busy.""" +def _open_pipe_handle(name: str): + """Open the named pipe (blocking) with retries while it's not yet listening/busy.""" import time - loop = asyncio.get_running_loop() + import pywintypes + import win32con + import win32file + last = None - for _ in range(50): # ~5s of retries for pipe-busy / not-yet-listening + for _ in range(100): # ~10s of retries try: - reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - transport, _proto = await loop.create_pipe_connection(lambda p=protocol: p, name) # type: ignore[attr-defined] # noqa: B023 - writer = asyncio.StreamWriter(transport, protocol, reader, loop) - return reader, writer - except (FileNotFoundError, OSError) as exc: # pipe not ready / all instances busy + return win32file.CreateFile( + name, + win32con.GENERIC_READ | win32con.GENERIC_WRITE, + 0, + None, + win32con.OPEN_EXISTING, + 0, + None, + ) + except pywintypes.error as exc: last = exc time.sleep(0.1) - raise last or ConnectionError(f"could not connect to pipe {name}") + raise ConnectionError(f"could not connect to pipe {name}: {last}") def _dbg(msg: str) -> None: @@ -171,9 +241,9 @@ def _dbg(msg: str) -> None: async def _main(job: dict) -> int: _dbg(f"connecting transport={job.get('transport')} endpoint={job.get('endpoint')}") - reader, writer = await _connect(job) + channel = await _connect(job) _dbg("connected; starting rpc client") - client = _RpcClient(reader, writer) + client = _RpcClient(channel) client.start() _dbg("sending hello") await client.hello(job["token"]) @@ -188,7 +258,7 @@ async def _main(job: dict) -> int: try: value = await _run_user_code(job["code"], exec_globals) await client.request("result", {"value": _jsonable(value)}) - writer.close() + channel.close() return 0 except Exception as exc: # report guest exceptions as structured output print(f"{type(exc).__name__}: {exc}", file=sys.stderr) From 163c3282fc11e37d8331d5ad0d618b5c279e2619 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 15:32:18 +0100 Subject: [PATCH 07/12] fix(windows): use overlapped pipe I/O to break the second round-trip deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the host pipe server and the guest opened the named pipe as synchronous (non-overlapped) handles. On Windows a pending blocking ReadFile serializes any concurrent WriteFile on the same handle, so once the guest's read loop was parked waiting for a reply, its next request write deadlocked behind that read — the handshake's first round-trip squeaked through on a startup race, but list_tools (the second) hung with the host never receiving it. Open both ends with FILE_FLAG_OVERLAPPED and drive ReadFile/WriteFile/ ConnectNamedPipe via OVERLAPPED + GetOverlappedResult, each with its own event, so read and write no longer serialize at the handle. Serialize writes with a lock on both ends so concurrent tool-call frames don't interleave. Signed-off-by: chris hay --- .../execution/isolation/_winpipe.py | 97 ++++++++++++++----- .../execution/isolation/guest_bootstrap.py | 84 ++++++++++++---- 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/src/chuk_tool_processor/execution/isolation/_winpipe.py b/src/chuk_tool_processor/execution/isolation/_winpipe.py index 2b10d1a..942f4d1 100644 --- a/src/chuk_tool_processor/execution/isolation/_winpipe.py +++ b/src/chuk_tool_processor/execution/isolation/_winpipe.py @@ -31,9 +31,11 @@ import pywintypes import win32api import win32con +import win32event import win32file import win32pipe import win32security +import winerror from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.transport import BrokerListener, ClientHandler, MessageChannel @@ -44,6 +46,50 @@ # Win32 constants (not reliably exposed by win32con across pywin32 versions). _PIPE_ACCESS_DUPLEX = 0x00000003 _FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000 +_FILE_FLAG_OVERLAPPED = 0x40000000 + + +def _ov_read_exact(handle: Any, n: int) -> bytes: + """Read exactly ``n`` bytes using overlapped I/O. + + The pipe handle is opened overlapped so a pending read and a concurrent write + don't serialize at the handle (a synchronous handle would deadlock: the reply + write would block behind the request read that is waiting for that very reply). + """ + chunks = bytearray() + while len(chunks) < n: + want = n - len(chunks) + ov = pywintypes.OVERLAPPED() + ov.hEvent = win32event.CreateEvent(None, True, False, None) + try: + buf = win32file.AllocateReadBuffer(want) + try: + win32file.ReadFile(handle, buf, ov) + except pywintypes.error as exc: + if exc.winerror != winerror.ERROR_IO_PENDING: + raise + nread = win32file.GetOverlappedResult(handle, ov, True) + if nread == 0: + raise ConnectionError("pipe closed") + chunks.extend(memoryview(buf)[:nread]) + finally: + win32file.CloseHandle(ov.hEvent) + return bytes(chunks) + + +def _ov_write_all(handle: Any, data: bytes) -> None: + """Write all of ``data`` using overlapped I/O (see :func:`_ov_read_exact`).""" + ov = pywintypes.OVERLAPPED() + ov.hEvent = win32event.CreateEvent(None, True, False, None) + try: + try: + win32file.WriteFile(handle, data, ov) + except pywintypes.error as exc: + if exc.winerror != winerror.ERROR_IO_PENDING: + raise + win32file.GetOverlappedResult(handle, ov, True) + finally: + win32file.CloseHandle(ov.hEvent) def _pipe_security_attributes() -> Any: @@ -74,18 +120,9 @@ def __init__(self, handle: Any) -> None: self._handle = handle self._write_lock = asyncio.Lock() - def _read_exact(self, n: int) -> bytes: - chunks = bytearray() - while len(chunks) < n: - _hr, data = win32file.ReadFile(self._handle, n - len(chunks)) - if not data: - raise ConnectionError("pipe closed") - chunks.extend(data) - return bytes(chunks) - def _recv_blocking(self) -> dict[str, Any]: - (length,) = _LEN.unpack(self._read_exact(_LEN.size)) - body = self._read_exact(length) + (length,) = _LEN.unpack(_ov_read_exact(self._handle, _LEN.size)) + body = _ov_read_exact(self._handle, length) import json return json.loads(body.decode("utf-8")) @@ -99,7 +136,7 @@ async def recv(self) -> dict[str, Any]: async def send(self, obj: dict[str, Any]) -> None: data = _wire.encode(obj) async with self._write_lock: - await asyncio.get_running_loop().run_in_executor(None, win32file.WriteFile, self._handle, data) + await asyncio.get_running_loop().run_in_executor(None, _ov_write_all, self._handle, data) async def aclose(self) -> None: import contextlib @@ -134,7 +171,7 @@ def start(self) -> None: def _make_instance(self) -> Any: sa = self._sa - open_mode = _PIPE_ACCESS_DUPLEX + open_mode = _PIPE_ACCESS_DUPLEX | _FILE_FLAG_OVERLAPPED if self._first: open_mode |= _FILE_FLAG_FIRST_PIPE_INSTANCE self._first = False @@ -144,21 +181,37 @@ def _make_instance(self) -> Any: self.name, open_mode, pipe_mode, win32pipe.PIPE_UNLIMITED_INSTANCES, _BUF, _BUF, 0, sa ) + def _connect(self, handle: Any) -> bool: + """Wait (overlapped) for a client to connect. Returns False on error.""" + ov = pywintypes.OVERLAPPED() + ov.hEvent = win32event.CreateEvent(None, True, False, None) + try: + try: + win32pipe.ConnectNamedPipe(handle, ov) + except pywintypes.error as exc: + # ERROR_PIPE_CONNECTED (535): a client connected before we waited. + if getattr(exc, "winerror", None) == winerror.ERROR_PIPE_CONNECTED: + return True + if getattr(exc, "winerror", None) != winerror.ERROR_IO_PENDING: + return False + win32file.GetOverlappedResult(handle, ov, True) + return True + except pywintypes.error: + return False + finally: + win32file.CloseHandle(ov.hEvent) + def _run(self) -> None: while not self._stop.is_set(): try: handle = self._make_instance() except Exception: # noqa: BLE001 - can't create pipe; stop break - try: - win32pipe.ConnectNamedPipe(handle, None) - except pywintypes.error as exc: - # ERROR_PIPE_CONNECTED (535): a client connected before we waited. - if getattr(exc, "winerror", None) != 535: - win32file.CloseHandle(handle) - if self._stop.is_set(): - break - continue + if not self._connect(handle): + win32file.CloseHandle(handle) + if self._stop.is_set(): + break + continue if self._stop.is_set(): win32file.CloseHandle(handle) break diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index ae4ed3a..f7b13f2 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -154,42 +154,85 @@ def close(self) -> None: self._writer.close() +def _ov_read_exact(handle, n: int) -> bytes: + """Read exactly ``n`` bytes with overlapped I/O. + + The handle is opened overlapped so a pending read and a concurrent write don't + serialize (a synchronous handle deadlocks: the request write would block behind + the read that is waiting for that request's reply). + """ + import pywintypes + import win32event + import win32file + import winerror + + chunks = bytearray() + while len(chunks) < n: + want = n - len(chunks) + ov = pywintypes.OVERLAPPED() + ov.hEvent = win32event.CreateEvent(None, True, False, None) + try: + buf = win32file.AllocateReadBuffer(want) + try: + win32file.ReadFile(handle, buf, ov) + except pywintypes.error as exc: + if exc.winerror != winerror.ERROR_IO_PENDING: + raise + nread = win32file.GetOverlappedResult(handle, ov, True) + if nread == 0: + raise ConnectionError("pipe closed") + chunks.extend(memoryview(buf)[:nread]) + finally: + win32file.CloseHandle(ov.hEvent) + return bytes(chunks) + + +def _ov_write_all(handle, data: bytes) -> None: + """Write all of ``data`` with overlapped I/O (see :func:`_ov_read_exact`).""" + import pywintypes + import win32event + import win32file + import winerror + + ov = pywintypes.OVERLAPPED() + ov.hEvent = win32event.CreateEvent(None, True, False, None) + try: + try: + win32file.WriteFile(handle, data, ov) + except pywintypes.error as exc: + if exc.winerror != winerror.ERROR_IO_PENDING: + raise + win32file.GetOverlappedResult(handle, ov, True) + finally: + win32file.CloseHandle(ov.hEvent) + + class _PipeChannel: """Framed-JSON channel over a connected Windows named-pipe handle. - Uses blocking win32 ReadFile/WriteFile in the default executor — the asyncio - Proactor pipe transport drops frames after the first, so we mirror the host's - blocking approach instead. + Uses overlapped win32 ReadFile/WriteFile in the default executor. Overlapped + I/O is required: a synchronous handle serializes a pending read against a + concurrent write, which deadlocks the second request/reply round-trip. Writes + are serialized by a lock so concurrent tool calls don't interleave frames. """ def __init__(self, handle): self._handle = handle - - def _read_exact(self, n: int) -> bytes: - import win32file - - chunks = bytearray() - while len(chunks) < n: - _hr, data = win32file.ReadFile(self._handle, n - len(chunks)) - if not data: - raise ConnectionError("pipe closed") - chunks.extend(data) - return bytes(chunks) + self._write_lock = asyncio.Lock() def _recv_blocking(self) -> dict: import struct - (length,) = struct.unpack(">I", self._read_exact(4)) - return json.loads(self._read_exact(length).decode("utf-8")) + (length,) = struct.unpack(">I", _ov_read_exact(self._handle, 4)) + return json.loads(_ov_read_exact(self._handle, length).decode("utf-8")) async def recv(self) -> dict: return await asyncio.get_running_loop().run_in_executor(None, self._recv_blocking) async def send(self, obj: dict) -> None: - import win32file - data = _wire.encode(obj) - await asyncio.get_running_loop().run_in_executor(None, win32file.WriteFile, self._handle, data) + async with self._write_lock: + await asyncio.get_running_loop().run_in_executor(None, _ov_write_all, self._handle, data) def close(self) -> None: with contextlib.suppress(Exception): @@ -216,6 +259,7 @@ def _open_pipe_handle(name: str): import win32con import win32file + _FILE_FLAG_OVERLAPPED = 0x40000000 last = None for _ in range(100): # ~10s of retries try: @@ -225,7 +269,7 @@ def _open_pipe_handle(name: str): 0, None, win32con.OPEN_EXISTING, - 0, + _FILE_FLAG_OVERLAPPED, None, ) except pywintypes.error as exc: From 5b3e36cf969708845d6e41f6492038cc3b250096 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 16:24:22 +0100 Subject: [PATCH 08/12] fix(windows): close guest pipe on every exit path so the interpreter can exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the exception path the guest never closed the pipe channel. The reader parks in a blocking overlapped ReadFile inside a default-executor thread; with the handle still open that read never returns, so asyncio.run hangs waiting for the executor to drain and the guest is killed by the wall-clock timeout — reported as 'timeout' instead of 'guest_exception'. Close the channel (and cancel the reader) in a finally: CloseHandle aborts the pending overlapped read (ERROR_OPERATION_ABORTED), which the reader now maps to a clean ConnectionError, so the guest exits with its real non-zero code. Signed-off-by: chris hay --- .../execution/isolation/guest_bootstrap.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index f7b13f2..b726cdd 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -83,6 +83,11 @@ def __init__(self, channel): def start(self) -> None: self._reader_task = asyncio.create_task(self._read_loop()) + def stop(self) -> None: + if self._reader_task is not None: + self._reader_task.cancel() + self._reader_task = None + async def _read_loop(self) -> None: try: while True: @@ -175,10 +180,14 @@ def _ov_read_exact(handle, n: int) -> bytes: buf = win32file.AllocateReadBuffer(want) try: win32file.ReadFile(handle, buf, ov) + nread = win32file.GetOverlappedResult(handle, ov, True) except pywintypes.error as exc: - if exc.winerror != winerror.ERROR_IO_PENDING: - raise - nread = win32file.GetOverlappedResult(handle, ov, True) + if exc.winerror == winerror.ERROR_IO_PENDING: + nread = win32file.GetOverlappedResult(handle, ov, True) + else: + # broken pipe / handle closed while a read was pending + # (ERROR_OPERATION_ABORTED on shutdown) -> treat as EOF. + raise ConnectionError(f"pipe read failed: {exc}") from exc if nread == 0: raise ConnectionError("pipe closed") chunks.extend(memoryview(buf)[:nread]) @@ -293,20 +302,25 @@ async def _main(job: dict) -> int: await client.hello(job["token"]) _dbg("hello ack; requesting list_tools") - tools = await client.request("list_tools", {}) - _dbg(f"got {len(tools)} tools; running user code") - 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: + tools = await client.request("list_tools", {}) + _dbg(f"got {len(tools)} tools; running user code") + 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"]) + value = await _run_user_code(job["code"], exec_globals) await client.request("result", {"value": _jsonable(value)}) - channel.close() return 0 except Exception as exc: # report guest exceptions as structured output print(f"{type(exc).__name__}: {exc}", file=sys.stderr) raise + finally: + # Always tear the channel down. On Windows the reader parks in a blocking + # overlapped ReadFile in an executor thread; closing the handle aborts it + # so the interpreter can exit instead of hanging on executor shutdown. + client.stop() + channel.close() def main(argv: list[str]) -> int: From 38248c2cc9edba1458c40f180c8e4ae6b375b1b0 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 17:17:52 +0100 Subject: [PATCH 09/12] test(coverage): omit isolation guest/Windows modules from Linux coverage guest_bootstrap.py runs in a separate guest process/container so parent-process coverage never sees it on any platform, and _winpipe/_winproc/backends.windows are Windows-only. All are exercised by the dedicated isolation CI (isolation.yml: Docker on Linux, AppContainer + named pipe on Windows), not the mandatory Linux unit-coverage run, where they only dragged the total under the 70% gate. Signed-off-by: chris hay --- pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index baa5939..ce6dd1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,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] @@ -153,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] From f474a599e7192ae3fed504855599bc08a79e721f Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 17:27:18 +0100 Subject: [PATCH 10/12] =?UTF-8?q?chore(release):=200.25.0=20=E2=80=94=20ex?= =?UTF-8?q?perimental=20Windows=20AppContainer=20backend=20+=20pluggable?= =?UTF-8?q?=20broker=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: chris hay --- CHANGELOG.md | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0dc54..4cc2d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [0.25.0] + +### 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 diff --git a/pyproject.toml b/pyproject.toml index ce6dd1a..505637e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 29819b573d08854f79e354978ecb118e6d01b2cc Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 18:03:11 +0100 Subject: [PATCH 11/12] harden(isolation): authoritative namespace policy, processor routing, bounded broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes to the tool broker (the guest runs untrusted code and holds the broker token, so it can craft raw frames): - Namespace authority: a host-pinned namespace is enforced — a mismatching guest-supplied namespace is rejected, and tool resolution uses the dotted namespace.name form so the strategy resolves strictly in that namespace with no cross-namespace fuzzy fallback. Closes a namespace-restriction bypass. - Namespace-qualified allowlists: allowed_tools entries may be bare or namespace-qualified so an allowed name can't select a same-named tool elsewhere. - Canonical execution: brokered calls run through ToolExecutor instead of a direct tool.execute(), so sandboxed calls get the same wrappers/guards/observability; callers may inject a fully-wrapped executor. - Bounded lifecycle: in-flight host calls are tracked and cancelled on aclose(), and calls are refused once the guest has reported its result. Also: migrate IsolationLimits/IsolatedResult to Pydantic (frozen + extra=forbid for limits, field-level validation); extract the broker RPC protocol vocabulary (methods, frame/payload keys, transports, default namespace, debug env) into shared _wire constants used by host and guest instead of scattered string literals; add adversarial protocol-boundary tests (namespace substitution, qualified allowlist, call-after-result, concurrency, malformed frames) taking broker.py to 100% line coverage. Signed-off-by: chris hay --- CHANGELOG.md | 25 + .../execution/isolation/_winpipe.py | 2 +- .../execution/isolation/_wire.py | 50 ++ .../execution/isolation/backend.py | 23 +- .../isolation/backends/_subprocess.py | 5 +- .../execution/isolation/backends/docker.py | 5 +- .../execution/isolation/backends/windows.py | 5 +- .../execution/isolation/broker.py | 154 ++++-- .../execution/isolation/guest_bootstrap.py | 66 +-- .../execution/isolation/limits.py | 38 +- .../execution/isolation/result.py | 10 +- .../execution/isolation/runner.py | 5 +- .../execution/isolation/transport.py | 4 +- tests/execution/isolation/test_backends.py | 6 + .../isolation/test_broker_protocol.py | 475 ++++++++++++++++++ uv.lock | 2 +- 16 files changed, 764 insertions(+), 111 deletions(-) create mode 100644 tests/execution/isolation/test_broker_protocol.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc2d5e..1c946c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project are documented here. This project follows ## [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 diff --git a/src/chuk_tool_processor/execution/isolation/_winpipe.py b/src/chuk_tool_processor/execution/isolation/_winpipe.py index 942f4d1..81571ca 100644 --- a/src/chuk_tool_processor/execution/isolation/_winpipe.py +++ b/src/chuk_tool_processor/execution/isolation/_winpipe.py @@ -239,4 +239,4 @@ async def start_pipe_listener(handle_client: ClientHandler) -> BrokerListener: name = r"\\.\pipe\ctiso-" + uuid.uuid4().hex server = _PipeServer(name, handle_client, asyncio.get_running_loop()) server.start() - return BrokerListener(endpoint=name, transport="pipe", _server=server, _cleanup=None) + return BrokerListener(endpoint=name, transport=_wire.TRANSPORT_PIPE, _server=server, _cleanup=None) diff --git a/src/chuk_tool_processor/execution/isolation/_wire.py b/src/chuk_tool_processor/execution/isolation/_wire.py index 9245711..32faa29 100644 --- a/src/chuk_tool_processor/execution/isolation/_wire.py +++ b/src/chuk_tool_processor/execution/isolation/_wire.py @@ -24,6 +24,56 @@ # Hard cap on a single frame so a malicious guest cannot force a huge allocation. MAX_FRAME_BYTES = 8 * 1024 * 1024 +# --------------------------------------------------------------------------- # +# Broker RPC protocol vocabulary. +# +# Shared between the host broker and the guest bootstrap. It lives in this +# dependency-free module (copied next to the guest) so both ends reference the +# same names instead of duplicating string/keys literals across the boundary. +# --------------------------------------------------------------------------- # + +# Envelope / frame keys. +KEY_ID = "id" +KEY_METHOD = "method" +KEY_PARAMS = "params" +KEY_TOKEN = "token" +KEY_OK = "ok" +KEY_ERROR = "error" +KEY_VALUE = "value" + +# ``call_tool`` parameter keys (also the ``list_tools`` reply entry keys). +KEY_NAME = "name" +KEY_NAMESPACE = "namespace" +KEY_ARGUMENTS = "arguments" + +# RPC method names. +METHOD_HELLO = "hello" +METHOD_LIST_TOOLS = "list_tools" +METHOD_CALL_TOOL = "call_tool" +METHOD_RESULT = "result" + +# Job payload keys (host ``GuestJob.payload`` -> guest bootstrap). +KEY_CODE = "code" +KEY_ENDPOINT = "endpoint" +KEY_TRANSPORT = "transport" +KEY_INITIAL_VARS = "initial_vars" +KEY_LIMITS = "limits" +KEY_CPU_TIMEOUT = "cpu_timeout" +KEY_MEMORY_BYTES = "memory_bytes" +KEY_MAX_PROCESSES = "max_processes" +KEY_MAX_OUTPUT_BYTES = "max_output_bytes" + +# Transport kinds. +TRANSPORT_UNIX = "unix" +TRANSPORT_PIPE = "pipe" + +# Default tool namespace when the host pins none. +DEFAULT_NAMESPACE = "default" + +# Env flag that turns on host + guest connection checkpoints (to stderr). +GUEST_DEBUG_ENV = "CTP_GUEST_DEBUG" +GUEST_DEBUG_ON = "1" + def encode(obj: Any) -> bytes: """Encode a message object to a length-prefixed JSON frame.""" diff --git a/src/chuk_tool_processor/execution/isolation/backend.py b/src/chuk_tool_processor/execution/isolation/backend.py index 53cc873..efe767f 100644 --- a/src/chuk_tool_processor/execution/isolation/backend.py +++ b/src/chuk_tool_processor/execution/isolation/backend.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from typing import Any, Protocol, runtime_checkable +from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.limits import IsolationLimits @@ -48,17 +49,17 @@ def payload(self, *, endpoint: str, transport: str) -> dict[str, Any]: """ lim = self.limits return { - "code": self.code, - "namespace": self.namespace, - "token": self.token, - "endpoint": endpoint, - "transport": transport, - "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, + _wire.KEY_CODE: self.code, + _wire.KEY_NAMESPACE: self.namespace, + _wire.KEY_TOKEN: self.token, + _wire.KEY_ENDPOINT: endpoint, + _wire.KEY_TRANSPORT: transport, + _wire.KEY_INITIAL_VARS: self.initial_vars, + _wire.KEY_LIMITS: { + _wire.KEY_CPU_TIMEOUT: lim.cpu_timeout, + _wire.KEY_MEMORY_BYTES: lim.memory_bytes, + _wire.KEY_MAX_PROCESSES: lim.max_processes, + _wire.KEY_MAX_OUTPUT_BYTES: lim.max_output_bytes, }, } diff --git a/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py index 391193f..c875e27 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py +++ b/src/chuk_tool_processor/execution/isolation/backends/_subprocess.py @@ -22,6 +22,7 @@ from collections.abc import Callable from dataclasses import dataclass +from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.backend import GuestJob, GuestOutcome from chuk_tool_processor.logging import get_logger @@ -84,7 +85,9 @@ def _extra_env(self) -> dict[str, str]: # -- main flow --------------------------------------------------------- # - async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "unix") -> GuestOutcome: + async def run_guest( + self, job: GuestJob, *, host_endpoint: str, transport: str = _wire.TRANSPORT_UNIX + ) -> GuestOutcome: workdir = tempfile.mkdtemp(prefix="ctiso-") os.chmod(workdir, 0o700) try: diff --git a/src/chuk_tool_processor/execution/isolation/backends/docker.py b/src/chuk_tool_processor/execution/isolation/backends/docker.py index 1cf4df3..16dd3f4 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/docker.py +++ b/src/chuk_tool_processor/execution/isolation/backends/docker.py @@ -17,6 +17,7 @@ import os import shutil +from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.backend import GuestJob, GuestOutcome from chuk_tool_processor.execution.isolation.backends._subprocess import SubprocessBackend, _LaunchCtx @@ -103,7 +104,9 @@ def _wrapper_argv(self, ctx: _LaunchCtx, job: GuestJob) -> list[str]: ] return argv - async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "unix") -> GuestOutcome: + async def run_guest( + self, job: GuestJob, *, host_endpoint: str, transport: str = _wire.TRANSPORT_UNIX + ) -> 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() diff --git a/src/chuk_tool_processor/execution/isolation/backends/windows.py b/src/chuk_tool_processor/execution/isolation/backends/windows.py index a77a7cf..7ffbff3 100644 --- a/src/chuk_tool_processor/execution/isolation/backends/windows.py +++ b/src/chuk_tool_processor/execution/isolation/backends/windows.py @@ -30,6 +30,7 @@ import tempfile from typing import Any +from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.backend import GuestJob, GuestOutcome from chuk_tool_processor.logging import get_logger @@ -66,7 +67,9 @@ def is_available(self) -> bool: except Exception: # noqa: BLE001 return False - async def run_guest(self, job: GuestJob, *, host_endpoint: str, transport: str = "pipe") -> GuestOutcome: + async def run_guest( + self, job: GuestJob, *, host_endpoint: str, transport: str = _wire.TRANSPORT_PIPE + ) -> GuestOutcome: # Deferred so this module imports cleanly on non-Windows. import asyncio diff --git a/src/chuk_tool_processor/execution/isolation/broker.py b/src/chuk_tool_processor/execution/isolation/broker.py index c8487b5..e547c10 100644 --- a/src/chuk_tool_processor/execution/isolation/broker.py +++ b/src/chuk_tool_processor/execution/isolation/broker.py @@ -23,9 +23,12 @@ import json 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.execution.isolation.transport import BrokerListener, MessageChannel, start_listener +from chuk_tool_processor.execution.tool_executor import ToolExecutor from chuk_tool_processor.logging import get_logger +from chuk_tool_processor.models.tool_call import ToolCall from chuk_tool_processor.registry.interface import ToolRegistryInterface logger = get_logger("chuk_tool_processor.execution.isolation.broker") @@ -34,7 +37,7 @@ def _dbg(msg: str) -> None: import os - if os.environ.get("CTP_GUEST_DEBUG") == "1": + if os.environ.get(_wire.GUEST_DEBUG_ENV) == _wire.GUEST_DEBUG_ON: import sys print(f"[host] {msg}", file=sys.stderr, flush=True) @@ -73,6 +76,7 @@ def __init__( limits: IsolationLimits, namespace: str | None = None, allowed_tools: set[str] | None = None, + executor: ToolExecutor | None = None, ) -> None: self._registry = registry self._token = token @@ -80,11 +84,23 @@ def __init__( self._namespace = namespace self._allowed_tools = allowed_tools + # Tool calls run through the canonical processor path so sandboxed calls + # get the same wrappers/guards/observability as any other call. Callers + # may inject a fully-wrapped executor; otherwise we build (and own) a + # default one over the same registry. + self._executor = executor or ToolExecutor(registry=registry, default_timeout=limits.wall_timeout) + self._owns_executor = executor is None + self._listener: BrokerListener | None = None self._call_count = 0 self._count_lock = asyncio.Lock() + # In-flight dispatch tasks, tracked so a guest that goes away (or times + # out) cannot leave privileged host tool calls running unattended. + self._tasks: set[asyncio.Task[Any]] = set() + self._closing = False + self._result_future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() # -- lifecycle --------------------------------------------------------- # @@ -96,10 +112,25 @@ async def start(self) -> str: return self._listener.endpoint async def aclose(self) -> None: - """Stop the server and clean up its endpoint.""" + """Stop the server, refuse new calls, and cancel any host calls still running.""" + self._closing = True if self._listener is not None: await self._listener.aclose() self._listener = None + # Cancel outstanding tool calls: once the sandbox run is over (guest + # finished, died, or timed out) no privileged host call should keep + # running. Cancellation is cooperative — a tool that ignores it may still + # finish its own side effect — but we no longer wait on it. + tasks = list(self._tasks) + self._tasks.clear() + for task in tasks: + task.cancel() + for task in tasks: + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + if self._owns_executor: + with contextlib.suppress(Exception): + await self._executor.shutdown() if not self._result_future.done(): self._result_future.cancel() @@ -131,46 +162,62 @@ def has_result(self) -> bool: async def _handle_client(self, channel: MessageChannel) -> None: try: hello = await channel.recv() - if hello.get("method") != "hello" or hello.get("token") != self._token: + if hello.get(_wire.KEY_METHOD) != _wire.METHOD_HELLO or hello.get(_wire.KEY_TOKEN) != self._token: logger.warning("Rejected guest connection: bad handshake") - await self._reply(channel, {"id": hello.get("id"), "ok": False, "error": "unauthorized"}) + await self._reply(channel, self._err(hello.get(_wire.KEY_ID), "unauthorized")) return - await self._reply(channel, {"id": hello.get("id"), "ok": True}) + await self._reply(channel, {_wire.KEY_ID: hello.get(_wire.KEY_ID), _wire.KEY_OK: True}) - while True: + while not self._closing: try: msg = await channel.recv() except (EOFError, asyncio.IncompleteReadError, ConnectionError): break - _dbg(f"recv {msg.get('method')} id={msg.get('id')}") - # Each request handled concurrently so guest asyncio.gather works. - asyncio.create_task(self._dispatch(msg, channel)) + _dbg(f"recv {msg.get(_wire.KEY_METHOD)} id={msg.get(_wire.KEY_ID)}") + # Each request handled concurrently so guest asyncio.gather works; + # tracked so aclose() can cancel calls a departing guest left running. + task = asyncio.create_task(self._dispatch(msg, channel)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) except Exception as exc: # noqa: BLE001 - broker must never crash the host logger.debug("Broker connection error: %s", exc) finally: await channel.aclose() async def _dispatch(self, msg: dict[str, Any], channel: MessageChannel) -> None: - method = msg.get("method") - msg_id = msg.get("id") + method = msg.get(_wire.KEY_METHOD) + msg_id = msg.get(_wire.KEY_ID) _dbg(f"dispatch {method} id={msg_id}") try: - if method == "list_tools": - await self._reply(channel, {"id": msg_id, "ok": True, "value": await self._list_tools()}) + if method == _wire.METHOD_LIST_TOOLS: + await self._reply(channel, self._ok(msg_id, await self._list_tools())) _dbg(f"replied {method} id={msg_id}") - elif method == "call_tool": - value = await self._call_tool(msg.get("params") or {}) - await self._reply(channel, {"id": msg_id, "ok": True, "value": value}) - elif method == "result": + elif method == _wire.METHOD_CALL_TOOL: + # Stop honouring tool calls once the run has produced its result + # or the broker is shutting down — no privileged work after the end. + if self._closing or self._result_future.done(): + await self._reply(channel, self._err(msg_id, "run already completed")) + return + value = await self._call_tool(msg.get(_wire.KEY_PARAMS) or {}) + await self._reply(channel, self._ok(msg_id, value)) + elif method == _wire.METHOD_RESULT: if not self._result_future.done(): - self._result_future.set_result((msg.get("params") or {}).get("value")) - await self._reply(channel, {"id": msg_id, "ok": True}) + self._result_future.set_result((msg.get(_wire.KEY_PARAMS) or {}).get(_wire.KEY_VALUE)) + await self._reply(channel, {_wire.KEY_ID: msg_id, _wire.KEY_OK: True}) else: - await self._reply(channel, {"id": msg_id, "ok": False, "error": f"unknown method: {method}"}) + await self._reply(channel, self._err(msg_id, f"unknown method: {method}")) except _BrokerReject as exc: - await self._reply(channel, {"id": msg_id, "ok": False, "error": str(exc)}) + await self._reply(channel, self._err(msg_id, str(exc))) except Exception as exc: # noqa: BLE001 - surface as tool error, never crash host - await self._reply(channel, {"id": msg_id, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) + await self._reply(channel, self._err(msg_id, f"{type(exc).__name__}: {exc}")) + + @staticmethod + def _ok(msg_id: Any, value: Any) -> dict[str, Any]: + return {_wire.KEY_ID: msg_id, _wire.KEY_OK: True, _wire.KEY_VALUE: value} + + @staticmethod + def _err(msg_id: Any, error: str) -> dict[str, Any]: + return {_wire.KEY_ID: msg_id, _wire.KEY_OK: False, _wire.KEY_ERROR: error} async def _reply(self, channel: MessageChannel, obj: dict[str, Any]) -> None: with contextlib.suppress(Exception): @@ -178,28 +225,54 @@ async def _reply(self, channel: MessageChannel, obj: dict[str, Any]) -> None: # -- capabilities ------------------------------------------------------ # + def _is_allowed(self, namespace: str, name: str) -> bool: + """Whether ``namespace.name`` is permitted by the host allowlist. + + Entries may be bare (``"name"``) or namespace-qualified (``"ns.name"``). + Qualified entries pin the tool to one namespace; bare entries are the + convenience form and match the name in whatever namespace policy resolved. + """ + if self._allowed_tools is None: + return True + return name in self._allowed_tools or f"{namespace}.{name}" in self._allowed_tools + + def _resolve_namespace(self, requested: Any) -> str: + """Resolve the namespace, with host policy authoritative. + + The guest holds the broker token and can craft raw frames, so a guest- + supplied namespace must never widen access: if the runner pinned a + namespace, any other value is rejected rather than honoured. + """ + if self._namespace is not None: + if requested is not None and requested != self._namespace: + raise _BrokerReject(f"namespace not allowed: {requested}") + return self._namespace + if requested is not None and not isinstance(requested, str): + raise _BrokerReject("namespace must be a string") + return requested or _wire.DEFAULT_NAMESPACE + 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" + ns = getattr(info, "namespace", None) or _wire.DEFAULT_NAMESPACE if not isinstance(name, str): continue - if self._allowed_tools is not None and name not in self._allowed_tools: + if not self._is_allowed(ns, name): continue - out.append({"name": name, "namespace": ns}) + out.append({_wire.KEY_NAME: name, _wire.KEY_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 {} + name = params.get(_wire.KEY_NAME) + arguments = params.get(_wire.KEY_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}") + namespace = self._resolve_namespace(params.get(_wire.KEY_NAMESPACE)) + if not self._is_allowed(namespace, name): + raise _BrokerReject(f"tool not allowed: {namespace}.{name}") if not isinstance(arguments, dict): raise _BrokerReject("tool arguments must be an object") @@ -208,12 +281,21 @@ async def _call_tool(self, params: dict[str, Any]) -> Any: 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) + return _to_jsonable(await self._execute_tool(namespace, name, arguments)) + + async def _execute_tool(self, namespace: str, name: str, arguments: dict[str, Any]) -> Any: + # Route through the canonical executor. Use the dotted ``namespace.name`` + # form: the strategy resolves that strictly in the named namespace with NO + # cross-namespace fallback, so the host's namespace decision is preserved + # all the way to invocation (a bare name could fuzzy-match elsewhere). + call = ToolCall(tool=f"{namespace}.{name}", namespace=namespace, arguments=arguments) + results = await self._executor.execute([call]) + if not results: + raise _BrokerReject(f"tool produced no result: {namespace}.{name}") + result = results[0] + if result.error is not None: + raise _BrokerReject(result.error) + return result.result class _BrokerReject(Exception): diff --git a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py index b726cdd..c77abec 100644 --- a/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py +++ b/src/chuk_tool_processor/execution/isolation/guest_bootstrap.py @@ -21,10 +21,19 @@ import contextlib import json import os +import struct import sys import _wire # sibling copy placed next to this file by the backend +# Windows named-pipe connect retry budget (~10s) while the host pipe comes up. +_PIPE_CONNECT_ATTEMPTS = 100 +_PIPE_CONNECT_DELAY_S = 0.1 +# CreateFile flag for overlapped (async) I/O — see _PipeChannel for why it matters. +_FILE_FLAG_OVERLAPPED = 0x40000000 +# 4-byte big-endian length prefix, matching _wire's on-the-wire framing. +_HEADER = struct.Struct(">I") + def _apply_limits(limits: dict) -> None: """Best-effort in-process resource limits (defense in depth; POSIX only).""" @@ -41,13 +50,13 @@ def _set(res: int, value) -> None: except (ValueError, OSError): pass - cpu = limits.get("cpu_timeout") + cpu = limits.get(_wire.KEY_CPU_TIMEOUT) if cpu: _set(resource.RLIMIT_CPU, int(cpu) + 1) - mem = limits.get("memory_bytes") + mem = limits.get(_wire.KEY_MEMORY_BYTES) if mem and hasattr(resource, "RLIMIT_AS"): _set(resource.RLIMIT_AS, int(mem)) - procs = limits.get("max_processes") + procs = limits.get(_wire.KEY_MAX_PROCESSES) if procs and hasattr(resource, "RLIMIT_NPROC"): _set(resource.RLIMIT_NPROC, int(procs)) @@ -92,7 +101,7 @@ async def _read_loop(self) -> None: try: while True: msg = await self._channel.recv() - fut = self._pending.pop(msg.get("id"), None) + fut = self._pending.pop(msg.get(_wire.KEY_ID), None) if fut and not fut.done(): fut.set_result(msg) except (EOFError, asyncio.IncompleteReadError, ConnectionError, OSError): @@ -103,25 +112,26 @@ async def _read_loop(self) -> None: async def _roundtrip(self, frame: dict): msg_id = self._next_id self._next_id += 1 - frame["id"] = msg_id + frame[_wire.KEY_ID] = msg_id fut = asyncio.get_running_loop().create_future() self._pending[msg_id] = fut await self._channel.send(frame) reply = await fut - if not reply.get("ok"): - raise RuntimeError(reply.get("error") or "broker rejected request") - return reply.get("value") + if not reply.get(_wire.KEY_OK): + raise RuntimeError(reply.get(_wire.KEY_ERROR) or "broker rejected request") + return reply.get(_wire.KEY_VALUE) async def hello(self, token: str) -> None: - await self._roundtrip({"method": "hello", "token": token}) + await self._roundtrip({_wire.KEY_METHOD: _wire.METHOD_HELLO, _wire.KEY_TOKEN: token}) async def request(self, method: str, params: dict): - return await self._roundtrip({"method": method, "params": params}) + return await self._roundtrip({_wire.KEY_METHOD: method, _wire.KEY_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}) + params = {_wire.KEY_NAME: name, _wire.KEY_NAMESPACE: namespace, _wire.KEY_ARGUMENTS: kwargs} + return await client.request(_wire.METHOD_CALL_TOOL, params) _proxy.__name__ = name return _proxy @@ -230,9 +240,7 @@ def __init__(self, handle): self._write_lock = asyncio.Lock() def _recv_blocking(self) -> dict: - import struct - - (length,) = struct.unpack(">I", _ov_read_exact(self._handle, 4)) + (length,) = _HEADER.unpack(_ov_read_exact(self._handle, _HEADER.size)) return json.loads(_ov_read_exact(self._handle, length).decode("utf-8")) async def recv(self) -> dict: @@ -252,9 +260,9 @@ def close(self) -> None: async def _connect(job: dict): """Connect to the broker endpoint per the job's transport. Returns a channel.""" - transport = job.get("transport", "unix") - endpoint = job.get("endpoint") or job.get("socket_path") - if transport == "pipe": + transport = job.get(_wire.KEY_TRANSPORT, _wire.TRANSPORT_UNIX) + endpoint = job.get(_wire.KEY_ENDPOINT) + if transport == _wire.TRANSPORT_PIPE: return _PipeChannel(_open_pipe_handle(endpoint)) reader, writer = await asyncio.open_unix_connection(endpoint) return _StreamChannel(reader, writer) @@ -268,9 +276,8 @@ def _open_pipe_handle(name: str): import win32con import win32file - _FILE_FLAG_OVERLAPPED = 0x40000000 last = None - for _ in range(100): # ~10s of retries + for _ in range(_PIPE_CONNECT_ATTEMPTS): try: return win32file.CreateFile( name, @@ -283,34 +290,35 @@ def _open_pipe_handle(name: str): ) except pywintypes.error as exc: last = exc - time.sleep(0.1) + time.sleep(_PIPE_CONNECT_DELAY_S) raise ConnectionError(f"could not connect to pipe {name}: {last}") def _dbg(msg: str) -> None: - if os.environ.get("CTP_GUEST_DEBUG") == "1": + if os.environ.get(_wire.GUEST_DEBUG_ENV) == _wire.GUEST_DEBUG_ON: print(f"[guest] {msg}", file=sys.stderr, flush=True) async def _main(job: dict) -> int: - _dbg(f"connecting transport={job.get('transport')} endpoint={job.get('endpoint')}") + _dbg(f"connecting transport={job.get(_wire.KEY_TRANSPORT)} endpoint={job.get(_wire.KEY_ENDPOINT)}") channel = await _connect(job) _dbg("connected; starting rpc client") client = _RpcClient(channel) client.start() _dbg("sending hello") - await client.hello(job["token"]) + await client.hello(job[_wire.KEY_TOKEN]) _dbg("hello ack; requesting list_tools") try: - tools = await client.request("list_tools", {}) + tools = await client.request(_wire.METHOD_LIST_TOOLS, {}) _dbg(f"got {len(tools)} tools; running user code") - exec_globals: dict = dict(job.get("initial_vars") or {}) + exec_globals: dict = dict(job.get(_wire.KEY_INITIAL_VARS) or {}) for meta in tools: - exec_globals[meta["name"]] = _build_tool_proxy(client, meta["name"], meta["namespace"]) + name, namespace = meta[_wire.KEY_NAME], meta[_wire.KEY_NAMESPACE] + exec_globals[name] = _build_tool_proxy(client, name, namespace) - value = await _run_user_code(job["code"], exec_globals) - await client.request("result", {"value": _jsonable(value)}) + value = await _run_user_code(job[_wire.KEY_CODE], exec_globals) + await client.request(_wire.METHOD_RESULT, {_wire.KEY_VALUE: _jsonable(value)}) return 0 except Exception as exc: # report guest exceptions as structured output print(f"{type(exc).__name__}: {exc}", file=sys.stderr) @@ -329,7 +337,7 @@ def main(argv: list[str]) -> int: return 2 with open(argv[1], encoding="utf-8") as fh: job = json.load(fh) - _apply_limits(job.get("limits") or {}) + _apply_limits(job.get(_wire.KEY_LIMITS) or {}) try: return asyncio.run(_main(job)) except Exception as exc: # noqa: BLE001 - top-level guest failure diff --git a/src/chuk_tool_processor/execution/isolation/limits.py b/src/chuk_tool_processor/execution/isolation/limits.py index 7f734f5..53da0d4 100644 --- a/src/chuk_tool_processor/execution/isolation/limits.py +++ b/src/chuk_tool_processor/execution/isolation/limits.py @@ -10,7 +10,7 @@ from __future__ import annotations -from dataclasses import dataclass +from pydantic import BaseModel, ConfigDict, Field # Sensible defaults for running a short orchestration snippet. DEFAULT_WALL_TIMEOUT = 30.0 @@ -21,11 +21,15 @@ DEFAULT_MAX_PROCESSES = 64 -@dataclass(frozen=True) -class IsolationLimits: +class IsolationLimits(BaseModel): """ Resource ceilings for a single isolated execution. + Frozen and validated: field constraints reject non-positive limits at + construction (``pydantic.ValidationError`` is a ``ValueError``), and unknown + fields are refused so a typo in a security limit fails loudly rather than + being silently ignored. + Attributes: wall_timeout: Hard wall-clock ceiling in seconds. The backend kills the guest when it is exceeded. Always enforced. @@ -44,24 +48,12 @@ class IsolationLimits: 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 + model_config = ConfigDict(frozen=True, extra="forbid") - 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") + wall_timeout: float = Field(default=DEFAULT_WALL_TIMEOUT, gt=0) + cpu_timeout: float | None = Field(default=DEFAULT_CPU_TIMEOUT, gt=0) + memory_bytes: int | None = Field(default=DEFAULT_MEMORY_BYTES, gt=0) + max_output_bytes: int = Field(default=DEFAULT_MAX_OUTPUT_BYTES, gt=0) + max_tool_calls: int = Field(default=DEFAULT_MAX_TOOL_CALLS, ge=0) + max_processes: int | None = Field(default=DEFAULT_MAX_PROCESSES, gt=0) + allow_network: bool = False diff --git a/src/chuk_tool_processor/execution/isolation/result.py b/src/chuk_tool_processor/execution/isolation/result.py index c404328..30fe3f6 100644 --- a/src/chuk_tool_processor/execution/isolation/result.py +++ b/src/chuk_tool_processor/execution/isolation/result.py @@ -3,12 +3,12 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import Any +from pydantic import BaseModel, ConfigDict, Field -@dataclass -class IsolatedResult: + +class IsolatedResult(BaseModel): """ Outcome of running code through an :class:`IsolatedCodeRunner`. @@ -29,6 +29,8 @@ class IsolatedResult: timed_out: True if the guest was killed for exceeding the wall timeout. """ + model_config = ConfigDict(extra="forbid") + ok: bool value: Any = None error: str | None = None @@ -39,4 +41,4 @@ class IsolatedResult: duration: float = 0.0 backend: str = "" timed_out: bool = False - meta: dict[str, Any] = field(default_factory=dict) + 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 index a7931eb..492a1cd 100644 --- a/src/chuk_tool_processor/execution/isolation/runner.py +++ b/src/chuk_tool_processor/execution/isolation/runner.py @@ -16,6 +16,7 @@ import time from typing import Any +from chuk_tool_processor.execution.isolation import _wire from chuk_tool_processor.execution.isolation.backend import ( BackendUnavailableError, GuestJob, @@ -103,7 +104,9 @@ async def run( initial_vars=initial_vars or {}, ) started = time.monotonic() - outcome = await self.backend.run_guest(job, host_endpoint=endpoint, transport=broker.transport or "unix") + outcome = await self.backend.run_guest( + job, host_endpoint=endpoint, transport=broker.transport or _wire.TRANSPORT_UNIX + ) duration = time.monotonic() - started return self._assemble(outcome, broker, duration) finally: diff --git a/src/chuk_tool_processor/execution/isolation/transport.py b/src/chuk_tool_processor/execution/isolation/transport.py index 4f26d63..0a8d3e6 100644 --- a/src/chuk_tool_processor/execution/isolation/transport.py +++ b/src/chuk_tool_processor/execution/isolation/transport.py @@ -74,7 +74,7 @@ async def aclose(self) -> None: def default_transport() -> str: """The transport kind for this platform: ``"pipe"`` on Windows, else ``"unix"``.""" - return "pipe" if IS_WINDOWS else "unix" + return _wire.TRANSPORT_PIPE if IS_WINDOWS else _wire.TRANSPORT_UNIX @dataclass @@ -122,7 +122,7 @@ async def _on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) os.chmod(path, 0o600) return BrokerListener( endpoint=path, - transport="unix", + transport=_wire.TRANSPORT_UNIX, _server=server, _cleanup=lambda: shutil.rmtree(sock_dir, ignore_errors=True), ) diff --git a/tests/execution/isolation/test_backends.py b/tests/execution/isolation/test_backends.py index 0cb7f7f..991b66e 100644 --- a/tests/execution/isolation/test_backends.py +++ b/tests/execution/isolation/test_backends.py @@ -72,6 +72,12 @@ def test_allow_network_omits_none(self): # No "--network none" pairing when network is allowed. assert not ("--network" in argv and "none" in argv) + def test_pure_backend_properties(self): + b = DockerBackend() + assert isinstance(b.is_available(), bool) + assert b._python_exe() == "python" # runs the container's interpreter + assert b._apply_rlimits_in_preexec() is False # the container caps resources + # --------------------------------------------------------------------------- # # Bubblewrap argv construction (pure; runs on any OS) diff --git a/tests/execution/isolation/test_broker_protocol.py b/tests/execution/isolation/test_broker_protocol.py new file mode 100644 index 0000000..fea8aaf --- /dev/null +++ b/tests/execution/isolation/test_broker_protocol.py @@ -0,0 +1,475 @@ +# tests/execution/isolation/test_broker_protocol.py +""" +Adversarial, protocol-boundary tests for the host tool broker. + +These deliberately bypass the friendly guest proxies and drive ``ToolBroker`` +with raw frames — because the guest runs untrusted code with the broker token +and can craft any request it likes. A cooperative-client test would never expose +a namespace-substitution or allowlist bypass; these do. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +import pytest + +from chuk_tool_processor.execution.isolation import _wire +from chuk_tool_processor.execution.isolation.broker import ToolBroker, _dbg, _to_jsonable +from chuk_tool_processor.execution.isolation.limits import IsolationLimits + + +# --------------------------------------------------------------------------- # +# A registry with the SAME tool name in two namespaces, plus a privileged tool +# that lives only in the "admin" namespace. This is what a namespace-confused +# broker would leak. +# --------------------------------------------------------------------------- # +@dataclass +class _Info: + namespace: str + name: str + + +class _Tool: + def __init__(self, label: str) -> None: + self._label = label + + async def execute(self, **kwargs): + return {"tool": self._label, "args": kwargs} + + +_TABLE = { + ("safe", "add"): _Tool("safe.add"), + ("admin", "add"): _Tool("admin.add"), + ("admin", "danger"): _Tool("admin.danger"), +} + + +class MultiNsRegistry: + async def list_tools(self, namespace=None): + infos = [_Info(ns, name) for (ns, name) in _TABLE] + if namespace is None: + return infos + return [i for i in infos if i.namespace == namespace] + + async def get_tool(self, name, namespace="default"): + return _TABLE.get((namespace, name)) + + async def list_namespaces(self): + return ["safe", "admin"] + + +class _CollectChannel: + """Minimal MessageChannel that records what the broker sends back.""" + + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def recv(self) -> dict: # pragma: no cover - not used in these tests + raise ConnectionError("no incoming frames") + + async def send(self, obj: dict) -> None: + self.sent.append(obj) + + async def aclose(self) -> None: + pass + + +def _make_broker(**kw) -> ToolBroker: + kw.setdefault("token", "tok") + kw.setdefault("limits", IsolationLimits()) + return ToolBroker(MultiNsRegistry(), **kw) + + +async def _dispatch(broker: ToolBroker, method: str, *, params=None, msg_id: int = 1) -> dict: + """Push one raw frame through the broker's dispatch and return the reply.""" + channel = _CollectChannel() + frame = {_wire.KEY_ID: msg_id, _wire.KEY_METHOD: method} + if params is not None: + frame[_wire.KEY_PARAMS] = params + await broker._dispatch(frame, channel) + assert channel.sent, "broker sent no reply" + return channel.sent[-1] + + +def _call(name: str, namespace: str | None = None, arguments: dict | None = None) -> dict: + params: dict = {_wire.KEY_NAME: name, _wire.KEY_ARGUMENTS: arguments or {}} + if namespace is not None: + params[_wire.KEY_NAMESPACE] = namespace + return params + + +# --------------------------------------------------------------------------- # +# P0: namespace authority +# --------------------------------------------------------------------------- # +class TestNamespaceAuthority: + @pytest.mark.asyncio + async def test_guest_cannot_override_pinned_namespace(self): + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("danger", namespace="admin")) + assert reply[_wire.KEY_OK] is False + assert "namespace not allowed" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_pinned_namespace_resolves_locally_not_cross_namespace(self): + # Host pins "safe"; a call for "add" must hit safe.add, never admin.add, + # even though the strategy has cross-namespace fuzzy fallback. + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add", arguments={"x": 1})) + assert reply[_wire.KEY_OK] is True + assert reply[_wire.KEY_VALUE]["tool"] == "safe.add" + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_privileged_tool_in_other_namespace_unreachable(self): + # "danger" exists only in admin. With host pinned to safe and no explicit + # namespace, it must NOT fuzzy-resolve to admin.danger. + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("danger")) + assert reply[_wire.KEY_OK] is False + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_matching_namespace_is_allowed(self): + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add", namespace="safe")) + assert reply[_wire.KEY_OK] is True + finally: + await broker.aclose() + + +# --------------------------------------------------------------------------- # +# Namespace-qualified allowlist +# --------------------------------------------------------------------------- # +class TestQualifiedAllowlist: + @pytest.mark.asyncio + async def test_qualified_entry_pins_to_namespace(self): + # Allow only safe.add. With no pinned namespace, a call to admin.add must + # be refused even though the bare name "add" exists there. + broker = _make_broker(allowed_tools={"safe.add"}) + try: + ok = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add", namespace="safe")) + assert ok[_wire.KEY_OK] is True + blocked = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add", namespace="admin"), msg_id=2) + assert blocked[_wire.KEY_OK] is False + assert "not allowed" in blocked[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_bare_entry_matches_any_namespace(self): + broker = _make_broker(allowed_tools={"add"}) + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add", namespace="admin")) + assert reply[_wire.KEY_OK] is True + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_list_tools_filtered_by_allowlist(self): + broker = _make_broker(allowed_tools={"safe.add"}) + try: + reply = await _dispatch(broker, _wire.METHOD_LIST_TOOLS) + names = {(t[_wire.KEY_NAMESPACE], t[_wire.KEY_NAME]) for t in reply[_wire.KEY_VALUE]} + assert names == {("safe", "add")} + finally: + await broker.aclose() + + +# --------------------------------------------------------------------------- # +# Lifecycle: no privileged work after the run ends; in-flight calls cancelled +# --------------------------------------------------------------------------- # +class TestLifecycle: + @pytest.mark.asyncio + async def test_call_after_result_refused(self): + broker = _make_broker(namespace="safe") + try: + broker._result_future.set_result("done") + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add")) + assert reply[_wire.KEY_OK] is False + assert "run already completed" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_aclose_cancels_in_flight_tasks(self): + broker = _make_broker(namespace="safe") + + async def _slow() -> None: + await asyncio.sleep(30) + + task = asyncio.create_task(_slow()) + broker._tasks.add(task) + task.add_done_callback(broker._tasks.discard) + + await broker.aclose() + assert task.cancelled() or task.done() + + @pytest.mark.asyncio + async def test_max_tool_calls_enforced_at_protocol_level(self): + broker = _make_broker(namespace="safe", limits=IsolationLimits(max_tool_calls=1)) + try: + first = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add")) + assert first[_wire.KEY_OK] is True + second = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add"), msg_id=2) + assert second[_wire.KEY_OK] is False + assert "limit" in second[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_concurrent_calls_counted_once_each(self): + broker = _make_broker(namespace="safe") + try: + replies = await asyncio.gather( + _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add"), msg_id=1), + _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add"), msg_id=2), + _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add"), msg_id=3), + ) + assert all(r[_wire.KEY_OK] for r in replies) + assert broker.tool_calls == 3 + finally: + await broker.aclose() + + +# --------------------------------------------------------------------------- # +# Malformed / hostile frames +# --------------------------------------------------------------------------- # +class TestMalformedFrames: + @pytest.mark.asyncio + async def test_unknown_method(self): + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, "please_escape") + assert reply[_wire.KEY_OK] is False + assert "unknown method" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_call_tool_requires_string_name(self): + broker = _make_broker(namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params={_wire.KEY_NAME: 123}) + assert reply[_wire.KEY_OK] is False + assert "string 'name'" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_call_tool_arguments_must_be_object(self): + broker = _make_broker(namespace="safe") + try: + params = {_wire.KEY_NAME: "add", _wire.KEY_ARGUMENTS: [1, 2, 3]} + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=params) + assert reply[_wire.KEY_OK] is False + assert "object" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_non_string_namespace_rejected_when_unpinned(self): + broker = _make_broker() # no pinned namespace + try: + params = {_wire.KEY_NAME: "add", _wire.KEY_NAMESPACE: 999} + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=params) + assert reply[_wire.KEY_OK] is False + finally: + await broker.aclose() + + +# --------------------------------------------------------------------------- # +# Handshake: bad token is rejected +# --------------------------------------------------------------------------- # +class TestHandshake: + @pytest.mark.asyncio + async def test_bad_token_rejected(self): + broker = _make_broker(namespace="safe") + + class _Script: + def __init__(self) -> None: + self.sent: list[dict] = [] + self._frames = [{_wire.KEY_METHOD: _wire.METHOD_HELLO, _wire.KEY_TOKEN: "wrong", _wire.KEY_ID: 0}] + + async def recv(self) -> dict: + if self._frames: + return self._frames.pop(0) + raise ConnectionError("eof") + + async def send(self, obj: dict) -> None: + self.sent.append(obj) + + async def aclose(self) -> None: + pass + + channel = _Script() + try: + await broker._handle_client(channel) + assert channel.sent[-1][_wire.KEY_OK] is False + assert channel.sent[-1][_wire.KEY_ERROR] == "unauthorized" + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_wire_recv_rejects_non_object(self): + # A well-framed but non-object JSON payload ("x") must be refused: the + # broker only ever exchanges JSON objects. + reader = asyncio.StreamReader() + body = b'"x"' + reader.feed_data(_wire._LEN.pack(len(body)) + body) + reader.feed_eof() + with pytest.raises(ValueError): + await _wire.recv(reader) + + +# --------------------------------------------------------------------------- # +# Edge cases (kept the broker file's own coverage honest) +# --------------------------------------------------------------------------- # +class _Boom: + async def execute(self, **kwargs): + raise RuntimeError("kaboom") + + +class _EmptyExecutor: + """Stands in for a ToolExecutor that returns no results.""" + + async def execute(self, calls): + return [] + + async def shutdown(self): # pragma: no cover - not exercised (injected, not owned) + pass + + +class TestBrokerEdges: + def test_to_jsonable_passthrough_and_fallbacks(self): + from dataclasses import dataclass + + assert _to_jsonable({"a": 1}) == {"a": 1} + # pydantic model -> model_dump + assert _to_jsonable(IsolationLimits(max_tool_calls=3))["max_tool_calls"] == 3 + # set is not JSON-serialisable -> repr fallback + assert isinstance(_to_jsonable({1, 2, 3}), str) + # dict that isn't directly serialisable (non-str key + set value) is + # rebuilt with stringified keys and jsonable values. + out = _to_jsonable({1: {5, 6}}) + assert list(out.keys()) == ["1"] and isinstance(out["1"], str) + # nested list of non-serialisable + assert isinstance(_to_jsonable([{1, 2}]), list) + + @dataclass + class _DC: + a: int + b: set + + assert _to_jsonable(_DC(a=1, b={9}))["a"] == 1 + + def test_dbg_enabled(self, monkeypatch, capsys): + monkeypatch.setenv(_wire.GUEST_DEBUG_ENV, _wire.GUEST_DEBUG_ON) + _dbg("hello-checkpoint") + assert "hello-checkpoint" in capsys.readouterr().err + + @pytest.mark.asyncio + async def test_endpoint_and_transport_none_before_start(self): + broker = _make_broker(namespace="safe") + assert broker.endpoint is None + assert broker.transport is None + assert broker.result("fallback") == "fallback" + assert broker.has_result() is False + await broker.aclose() + + @pytest.mark.asyncio + async def test_tool_exception_surfaced_as_error(self): + class Reg(MultiNsRegistry): + async def get_tool(self, name, namespace="default"): + return _Boom() if name == "boom" else await super().get_tool(name, namespace) + + async def list_tools(self, namespace=None): + return [_Info("safe", "boom")] + + broker = ToolBroker(Reg(), token="t", limits=IsolationLimits(), namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("boom")) + assert reply[_wire.KEY_OK] is False + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_list_tools_error_is_caught(self): + class Reg(MultiNsRegistry): + async def list_tools(self, namespace=None): + raise RuntimeError("registry down") + + broker = ToolBroker(Reg(), token="t", limits=IsolationLimits(), namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_LIST_TOOLS) + assert reply[_wire.KEY_OK] is False + assert "registry down" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_list_tools_skips_non_string_name(self): + class Reg(MultiNsRegistry): + async def list_tools(self, namespace=None): + return [_Info("safe", None), _Info("safe", "add")] # type: ignore[list-item] + + broker = ToolBroker(Reg(), token="t", limits=IsolationLimits(), namespace="safe") + try: + reply = await _dispatch(broker, _wire.METHOD_LIST_TOOLS) + names = [t[_wire.KEY_NAME] for t in reply[_wire.KEY_VALUE]] + assert names == ["add"] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_injected_executor_with_no_result(self): + broker = ToolBroker( + MultiNsRegistry(), token="t", limits=IsolationLimits(), namespace="safe", executor=_EmptyExecutor() + ) + try: + reply = await _dispatch(broker, _wire.METHOD_CALL_TOOL, params=_call("add")) + assert reply[_wire.KEY_OK] is False + assert "no result" in reply[_wire.KEY_ERROR] + finally: + await broker.aclose() + + @pytest.mark.asyncio + async def test_handshake_recv_error_is_swallowed(self): + broker = _make_broker(namespace="safe") + + class _Bad: + async def recv(self): + raise ValueError("corrupt") + + async def send(self, obj): # pragma: no cover - never reached + pass + + async def aclose(self): + pass + + # Must not raise: the broker must never crash the host. + await broker._handle_client(_Bad()) + await broker.aclose() + + +@pytest.mark.skipif(not hasattr(__import__("os"), "getuid"), reason="POSIX unix-socket listener only") +class TestBrokerListenerPosix: + @pytest.mark.asyncio + async def test_start_exposes_unix_endpoint(self): + broker = _make_broker(namespace="safe") + endpoint = await broker.start() + try: + assert endpoint and broker.endpoint == endpoint + assert broker.transport == _wire.TRANSPORT_UNIX + finally: + await broker.aclose() + assert broker.endpoint is None diff --git a/uv.lock b/uv.lock index 1eda616..23ee08c 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "chuk-tool-processor" -version = "0.24.0" +version = "0.25.0" source = { editable = "." } dependencies = [ { name = "chuk-mcp" }, From 359c7990e9bc97b4cddd387deedb27c7e743c8c6 Mon Sep 17 00:00:00 2001 From: chris hay Date: Wed, 29 Jul 2026 18:31:43 +0100 Subject: [PATCH 12/12] docs: accurate Seatbelt summary wording + refresh roadmap to v0.25 - Seatbelt is described as 'network and write confinement with partial read protection' (not full isolation) in the backend summary table, matching the detailed footnote, and notes sandbox-exec is deprecated by Apple. - Roadmap: add the shipped isolated-execution/security work (v0.23-v0.25), update the 'Shipped' label and last-updated date, drop the stale v0.23 tag from the Next section. Signed-off-by: chris hay --- docs/isolated_execution.md | 14 +++++++++----- roadmap.md | 24 +++++++++++++++++++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/isolated_execution.md b/docs/isolated_execution.md index 78e90d9..f24b3b9 100644 --- a/docs/isolated_execution.md +++ b/docs/isolated_execution.md @@ -82,7 +82,7 @@ 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` | @@ -100,11 +100,15 @@ 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 diff --git a/roadmap.md b/roadmap.md index 038b2aa..1ec8907 100644 --- a/roadmap.md +++ b/roadmap.md @@ -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 @@ -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.