docs(spec): propose the gateway as parent for stdio, and say what it costs - #484
Merged
Conversation
Sections 4.3 and 7 still said the gateway measurement was an unsigned NV read and that TPM2_NV_Certify was the open half of #432, while section 5 in the same document said P2 is implemented. The document contradicted itself, and the two stale passages are the ones a reader reaches first. Both halves are closed and hardware-validated on an Azure Trusted Launch vTPM on 2026-08-01. Section 4.3 now describes the bracketing certify pair and the stateless post == H(pre || digest) check, section 7's row three names the pair, and the caveat under it is rewritten to the limit that actually remains: the appraisal proves what was extended, not that it was known-good, absent an expected value from the relying party. Host-dependent key provenance is unchanged and still stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…costs transport.md rules stdio out and rejects two bridging options, correctly: both put a translating component outside the enclave where it can inject or suppress tool calls before the gateway sees them, and the attestation report does not cover it. Both options share an assumption that does not hold once cMCP is in the path, that the agent spawns the MCP server. It does not. transport.md says so itself two sections later: the agent reaches only the gateway, and the runtime catalog is authoritative. The agent already does not spawn, address or reach servers. So the gateway spawns the stdio server as its own child, inside the enclave. Nothing crosses the boundary that does not cross it today: a process tree does not leave the VM by forking, and the objection in transport.md is about a process outside the enclave forking a child inside it, which is a different thing and remains impossible. It buys something HTTP upstreams cannot have. The gateway chooses when to exec, so it can digest the executable first and refuse to spawn on a mismatch. A pinned TLS fingerprint identifies an endpoint; a verified digest identifies the code. That is the natural identity for a server provenance record. The cost is stated rather than buried: the server then runs in the same isolation domain as the policy evaluator and the audit chain, and the gateway's launch measurement does not cover a child spawned later. The child's digest is operator-evident and hardware-rooted only as far as the gateway is, so it gets its own evidence classes, spawn-measured and spawn-unmeasured, instead of being folded into hardware_attestation. Proposal only. No code written, four open questions listed, and transport.md carries a pointer so the two do not silently disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imran-siddique
added a commit
that referenced
this pull request
Aug 9, 2026
… 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>
imran-siddique
added a commit
that referenced
this pull request
Aug 9, 2026
… it cannot check (#485) * feat(stdio): the gateway spawns the server, and refuses to spawn what 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> * feat(stdio): route stdio catalog entries through the spawned child 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. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Proposal, no code. You asked for the stdio bridge before server provenance. Working on it turned up a reason not to build a bridge at all.
What
transport.mdalready decided, and why it was rightstdio is out of scope, and both bridging options are rejected because they put a translating component outside the enclave, where it can inject or suppress tool calls before the gateway sees them and where the attestation report does not cover it. That reasoning holds.
The assumption underneath both options
Both assume the agent spawns the MCP server. That is how stdio works by default. It is not how anything works once cMCP is in the path — and
transport.mdsays so itself, two sections later:The agent already does not spawn, address or reach servers. It reaches the gateway.
The proposal
The gateway spawns the stdio server as its own child, inside the enclave.
Nothing crosses the boundary that does not cross it today. A process tree does not leave the VM by forking; SEV-SNP and TDX isolate a VM. The objection in
transport.mdis about a process outside the enclave forking a child inside it, which is a different thing and remains impossible.What it buys that no HTTP upstream can have: the gateway chooses when to
exec, so it can digest the executable and refuse to spawn on a mismatch. A pinned TLS fingerprint identifies an endpoint; a verified digest identifies the code. That is a stronger binding than Phase 1 makes about any upstream today, and it is the natural identity for the server provenance record — a package digest the gateway verified rather than a URL it trusted.What it costs, which is the part worth arguing about
The server then runs in the same isolation domain as the policy evaluator and the audit chain. A compromised server is inside the boundary. Memory isolation protects the gateway from the host, not from its own child. That is a real weakening relative to a network upstream, where the network does the isolating.
The gateway's launch measurement does not cover a child spawned later. The child's digest is recorded in the audit chain and sealed by the gateway's key, which makes it operator-evident and hardware-rooted only as far as the gateway itself is. Weaker than a launch measurement, so it gets its own evidence classes —
spawn-measuredandspawn-unmeasured— rather than being folded intohardware_attestation.Recommendation: adopt it, retire both bridging options, ship behind config with default off and
spawn-unmeasuredrefused unless explicitly enabled.Four open questions I did not answer
transport.mdgets a pointer to this file so the two do not silently disagree.🤖 Generated with Claude Code