Skip to content

feat(stdio): the gateway spawns the server, and refuses to spawn what it cannot check - #485

Merged
imran-siddique merged 2 commits into
mainfrom
feat/stdio-upstream
Aug 9, 2026
Merged

feat(stdio): the gateway spawns the server, and refuses to spawn what it cannot check#485
imran-siddique merged 2 commits into
mainfrom
feat/stdio-upstream

Conversation

@imran-siddique

Copy link
Copy Markdown
Contributor

Implements #484, which you approved. Transport, catalog, config, proxy routing and the audit-chain evidence class.

What it does

The gateway spawns the stdio MCP server as its own child, inside the enclave, and measures the entrypoint against the catalog before exec. A mismatch refuses. A missing digest refuses unless attestation.allow_unmeasured_spawn is set, and then every call is recorded as spawn-unmeasured rather than passing quietly.

Those refusals are the control that pays for running a child inside the enclave, so they are the best-tested part of the change — including one asserting that exec is never reached on a refused spawn.

Two design corrections that came out of the tests

Pinning the executable is close to useless for an interpreted server. The executable is the interpreter, so every Python MCP server on a host shares one digest and the pin would happily match a completely different server. StdioSpawn.measure_target pins the entrypoint instead; the interpreter is still resolved and recorded, it is just not what the pin is about.

Executable and readable are different properties. A Windows Store Python is an App Execution Alias: it runs fine and cannot be opened for reading, so measuring it raises OSError. An unmeasurable target is now a refusal with an explanation rather than a crash. I hit this on the first test run.

Choices worth reviewing

One child per server per session, never pooled. A pool is faster and leaks state between sessions, which the audit chain cannot see.

Framing errors are fatal. A child that logs to stdout has desynchronized the JSON-RPC stream; skipping to the next parsable line means guessing which bytes answered which call. A response whose id does not match its request is fatal for the same reason — a result that cannot be attributed is not a result.

stderr never enters the audit chain. Diagnostics carry payloads and the chain is meant to be shareable, so content goes to the logger and only the byte count is exposed. A test writes an IBAN to stderr and asserts it does not reach the caller.

The evidence class is not collapsed. stdio reports spawn-measured / spawn-unmeasured; network reports tls-pinned / hash-only. They answer different questions, and merging them would let a digest and a certificate pin look like the same claim.

The schema conditions on transport rather than relaxing. A network transport still requires url and tls_fingerprint; stdio requires spawn. Dropping them from required outright would have let an http-sse entry ship with no TLS pin.

Tests

15 new, covering both refusal paths, pre-exec ordering, round trip, desynchronization, id mismatch, child exit, upstream error objects, stderr containment, and measurement/symlink resolution.

Local collection of the wider unit suite is blocked by the known agt-core 5.x shadowing (#472) — CI installs 4.1.0 from PyPI. The stdio, catalog and config suites pass in isolation (69 passed).

🤖 Generated with Claude Code

imran-siddique and others added 2 commits August 9, 2026 08:47
… it cannot check

Implements docs/spec/stdio-transport.md (#484). Transport layer and catalog
support; proxy routing is the next commit.

StdioServer measures, decides, then spawns, in that order, and a test asserts
exec is never reached on a refused spawn. A digest mismatch refuses. A missing
digest refuses unless allow_unmeasured_spawn is set, and then the call is
recorded as spawn-unmeasured rather than passing quietly. Those refusals are the
control that pays for running a child inside the enclave, so they are the
best-tested part of the file.

Two findings from writing the tests, both changing the design rather than the
tests:

Pinning the executable is close to useless for an interpreted server. The
executable is the interpreter, so every Python MCP server on a host shares one
digest and a pin would match a completely different server. StdioSpawn gains
measure_target, so a catalog running 'python server.py' pins the script, which
is the code that actually differs. The interpreter is still resolved and
recorded, it is just not what the pin is about.

Executable and readable are different properties. A Windows Store Python is an
App Execution Alias: it runs fine and cannot be opened for reading, so measuring
it raises OSError. An unmeasurable target is now a refusal with a message that
says why, not a crash.

Framing errors are fatal, deliberately. A child that logs to stdout has
desynchronized the JSON-RPC stream, and skipping to the next parsable line means
guessing which bytes answered which call. A response whose id does not match the
request is fatal for the same reason: a result that cannot be attributed is not a
result.

stderr never enters the audit chain. Diagnostics carry payloads and the chain is
meant to be shareable, so content goes to the logger and only the byte count is
exposed. A test writes an IBAN to stderr and asserts it does not appear in the
error surfaced to the caller.

The catalog schema conditions its requirements on transport rather than relaxing
them: a network transport still requires url and tls_fingerprint, stdio requires
spawn instead. Dropping those from the required list outright would have let an
http-sse entry ship with no TLS pin.

15 tests. Local collection of the wider unit suite is blocked by the known
agt-core 5.x shadowing (#472); the stdio and catalog suites pass in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second half of the stdio work: the proxy now routes a stdio catalog entry to a
spawned child instead of an HTTP POST, and the outcome reaches the audit chain.

One child per server for the life of a session, never pooled across sessions. A
server that holds anything in memory would otherwise carry it from one agent's
session into the next, and the audit chain cannot see that happen. Proxy.aclose
terminates them, so a session that ends leaves nothing running.

The evidence class is where this becomes visible to a verifier. A stdio response
reports spawn-measured or spawn-unmeasured; a network response still reports
tls-pinned or hash-only. The two are not collapsed, because they answer different
questions: one identifies an endpoint, the other identifies code. A stdio
response with no spawned server on record falls back to hash-only rather than
guessing.

attestation.allow_unmeasured_spawn is new and defaults to false, so an unpinned
server is not spawned at all. STATUS records it alongside the other defaults, and
the capability row no longer says stdio is unsupported.
@imran-siddique
imran-siddique merged commit 46cedb8 into main Aug 9, 2026
12 checks passed
@imran-siddique
imran-siddique deleted the feat/stdio-upstream branch August 9, 2026 15:55
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