Skip to content

feat: pluggable broker transport + experimental Windows AppContainer backend - #52

Merged
chrishayuk merged 12 commits into
mainfrom
feat/windows-isolation-backend
Jul 29, 2026
Merged

feat: pluggable broker transport + experimental Windows AppContainer backend#52
chrishayuk merged 12 commits into
mainfrom
feat/windows-isolation-backend

Conversation

@chrishayuk

Copy link
Copy Markdown
Collaborator

Adds Windows support to IsolatedCodeRunner, behind a pluggable broker transport so POSIX is unchanged.

Transport groundwork (verified on macOS/Linux)

  • Pluggable broker transport via 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. Existing backends (Local/Seatbelt/Docker/bubblewrap) are unaffected; Docker keeps its v0.24.0 fixes (pre-pull, --pull never, --user).

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 a 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; import-safe on POSIX, is_available() true only on Windows.

CI

  • isolation.yml now runs Docker integration on Linux and the experimental Windows pipe/AppContainer path on windows-latest (CTP_TEST_ISOLATION_WINDOWS=1 + pywin32). The mandatory matrix skips the Windows broker tests (opt-in only), so this PR's test.yml stays green while the Windows internals are iterated via isolation.yml.
  • isolation-windows extra (pywin32); Windows-only mypy overrides.

Heads-up: the Windows named-pipe/AppContainer internals have never run on real hardware — like the Docker backend, expect the isolation.yml windows job to need a few iterations before it's green. The mandatory checks (test.yml) should pass immediately.

Stacked on v0.24.0. WASM backend follows on a separate branch.

🤖 Generated with Claude Code

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 <chris.hay@uk.ibm.com>
…reter, 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 <chris.hay@uk.ibm.com>
…efine 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 <chris.hay@uk.ibm.com>
… locate pipe hang

Signed-off-by: chris hay <chris.hay@uk.ibm.com>
…late pipe hang

Signed-off-by: chris hay <chris.hay@uk.ibm.com>
…r 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 <chris.hay@uk.ibm.com>
…deadlock

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 <chris.hay@uk.ibm.com>
…can exit

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 <chris.hay@uk.ibm.com>
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 <chris.hay@uk.ibm.com>
…pluggable broker transport

Signed-off-by: chris hay <chris.hay@uk.ibm.com>
… bounded broker

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 <chris.hay@uk.ibm.com>
- 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 <chris.hay@uk.ibm.com>
@chrishayuk
chrishayuk merged commit 359c799 into main Jul 29, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant