Skip to content

docs(spec): propose the gateway as parent for stdio, and say what it costs - #484

Merged
imran-siddique merged 2 commits into
mainfrom
docs/stdio-gateway-as-parent
Aug 9, 2026
Merged

docs(spec): propose the gateway as parent for stdio, and say what it costs#484
imran-siddique merged 2 commits into
mainfrom
docs/stdio-gateway-as-parent

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

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.md already decided, and why it was right

stdio 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.md says so itself, two sections later:

The agent does not list individual MCP servers here. The runtime tool catalog is the authoritative list of available tools.

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.md is 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-measured and spawn-unmeasured — rather than being folded into hardware_attestation.

Recommendation: adopt it, retire both bridging options, ship behind config with default off and spawn-unmeasured refused unless explicitly enabled.

Four open questions I did not answer

  1. Lifecycle — one child per session, or a pool? A pool is faster and leaks state between sessions, which is exactly what the audit chain cannot see.
  2. stderr — capturing it risks payloads in a shareable artifact; discarding it loses the only signal when a child misbehaves.
  3. Framing — a child that logs to stdout desynchronizes the JSON-RPC stream. A parse failure has to be fatal, because resynchronizing means guessing which bytes were a response.
  4. Phase model — a spawn-measured child sits between "unattested network upstream" and "server attests itself in its own TEE". The Phase 1/2 line has no place for it.

transport.md gets a pointer to this file so the two do not silently disagree.

🤖 Generated with Claude Code

imran-siddique and others added 2 commits August 8, 2026 21:58
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
imran-siddique merged commit d359942 into main Aug 9, 2026
10 checks passed
@imran-siddique
imran-siddique deleted the docs/stdio-gateway-as-parent branch August 9, 2026 05:04
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>
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