feat(stdio): the gateway spawns the server, and refuses to spawn what it cannot check - #485
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 unlessattestation.allow_unmeasured_spawnis set, and then every call is recorded asspawn-unmeasuredrather 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
execis 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_targetpins 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
iddoes 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 reportstls-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
urlandtls_fingerprint; stdio requiresspawn. Dropping them fromrequiredoutright 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