diff --git a/docs/spec/stdio-transport.md b/docs/spec/stdio-transport.md new file mode 100644 index 00000000..4797dab5 --- /dev/null +++ b/docs/spec/stdio-transport.md @@ -0,0 +1,121 @@ +# stdio Transport: the Gateway as Parent + +--- +Status: Proposal +Written: 2026-08-09 +Supersedes: the stdio section of [transport.md](transport.md), if accepted +Stability: Unstable, no code written +--- + +## Why revisit a settled decision + +[`transport.md`](transport.md) records that stdio is out of scope, and its reasoning is +sound as far as it goes: + +> A subprocess cannot cross the TEE boundary: the agent process lives outside the enclave +> and cannot fork a child that executes inside isolated TEE memory. + +It then evaluates two bridging options, both of which put a translating component *outside* +the enclave, and rejects both — correctly. An untrusted segment at the trust boundary can +inject or suppress tool calls before the gateway ever sees them, and the attestation report +does not cover it. + +**Both options share an assumption that does not hold in this architecture: that the agent +spawns the MCP server.** That is how stdio works in a default MCP deployment. It is not how +anything works once cMCP is in the path. `transport.md` says so itself, in the agent +configuration section: + +> 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 TEE.** + +``` +Agent (outside) + │ HTTP/SSE, unchanged + ▼ +cMCP Gateway (inside TEE) + ├── spawns MCP server as a child process, inside the same enclave + └── speaks JSON-RPC 2.0 over the child's stdin/stdout +``` + +Nothing crosses the boundary that does not cross it today. The child is a child of a +process already inside the enclave, so it is inside the enclave: SEV-SNP and TDX isolate a +VM, and a process tree does not leave the VM by forking. The objection in `transport.md` +is about a process *outside* the enclave forking a child *inside* it, which is indeed +impossible and is not what this does. + +### What this gets that HTTP/SSE does not + +**The binary is measurable before it runs.** The gateway chooses when to exec, so it can +digest the executable and its arguments first and refuse to spawn on a mismatch. For an +HTTP upstream, the best available binding is a pinned TLS fingerprint, which identifies an +endpoint rather than the code behind it. This is a stronger claim than the one Phase 1 +currently makes about any upstream, and it is the natural identity for the server +provenance record: a package digest the gateway verified rather than a URL it trusted. + +**No network hop.** No TLS to pin, no MITM window, no `hash-only` evidence class for +upstreams that never got a certificate. + +## What it costs, stated plainly + +**A subprocess inside the enclave is a subprocess inside the enclave.** The MCP server's +code now runs in the same isolation domain as the policy evaluator and the audit chain. A +compromised server is inside the boundary, and the memory isolation that protects the +gateway from the host does not protect the gateway from its own child. This is a real +weakening relative to a network upstream, where the server is isolated by the network. + +Mitigations, in order of how much they actually buy: + +1. **Refuse to spawn what is not measured.** No digest match, no exec. This is the control + that matters; everything else is depth. +2. Drop privileges and apply seccomp/landlock to the child before exec. +3. Separate enclave per server for high-sensitivity catalogs, at the cost of an internal + network hop and much of the simplicity above. + +**The measurement problem is real.** The gateway's own attestation is taken at startup and +covers the gateway image. A child spawned later is not in the launch measurement. The +honest position is that the child's digest is recorded in the audit chain and in the +GatewayClaim, sealed by the gateway's key, which makes it operator-evident and +hardware-rooted only to the extent that the gateway itself is. That is a weaker claim than +the launch measurement and must be reported as a distinct evidence class, not folded into +`hardware_attestation`. + +## Evidence classes, extending the existing pair + +`LIMITATIONS.md` records `tls-pinned` and `hash-only` for upstream responses. stdio adds: + +| Class | Meaning | +|---|---| +| `spawn-measured` | The gateway digested the executable, matched it against the catalog, spawned it, and recorded the digest in the audit chain. The response came from that process. | +| `spawn-unmeasured` | The gateway spawned a child with no digest in the catalog to check against. Recorded, never silently treated as measured. Configuration should be able to refuse this. | + +## Open questions + +1. **Lifecycle.** One child per session, or a pool reused across sessions? A pool is + faster and leaks state between sessions, which is exactly the kind of cross-session + contamination the audit chain cannot see. +2. **stderr.** MCP servers write diagnostics there. Capturing it into the audit chain risks + payload leakage into an artifact meant to be shareable; discarding it loses the only + signal when a child misbehaves. +3. **Framing.** MCP stdio uses newline-delimited JSON-RPC. A child that writes an unframed + blob, or writes to stdout for logging, desynchronizes the stream. The reader must treat + a parse failure as a fatal session error rather than resynchronizing, because + resynchronizing means guessing which bytes were a response. +4. **Does this change the Phase 1 / Phase 2 line?** Phase 2 attests the server from inside + its own TEE. A spawn-measured stdio child is a third position between "unattested + network upstream" and "server attests itself", and the phase model does not currently + have a place for it. + +## Recommendation + +Adopt the gateway-as-parent model and retire both bridging options, which exist only to +serve an assumption this architecture already discarded. Implement behind configuration, +default off, with `spawn-measured` required and `spawn-unmeasured` refused unless +explicitly enabled. + +No code has been written against this proposal. diff --git a/docs/spec/tpm-security-model.md b/docs/spec/tpm-security-model.md index 3f2f9044..1b508b9c 100644 --- a/docs/spec/tpm-security-model.md +++ b/docs/spec/tpm-security-model.md @@ -46,11 +46,13 @@ What is still open is the **binding** between the two, and it cannot be closed w One sharp edge worth recording, because it produced a false pass during implementation: the TCG EK EKU (`2.23.133.8.1`) does **not** identify an Endorsement Key. Azure's real vTPM chain carries it on the *issuing CAs* (`Azure Cloud Virtual TPM CA - 11` and `... CA 2025`), where it means "may issue EK certificates". Matching on the EKU alone reported `ek_cert_chain` verified for a chain containing no EK at all. Identification requires the EKU **and** `ca=False`. -### 4.3 The gateway is not measured (#432, measurement landed, binding open) +### 4.3 The gateway is not measured (#432, closed) PCRs 0 through 7 cover firmware, option ROMs, boot configuration, and bootloader. Replacing the policy bundle or the gateway binary produced an identical measurement, so the stated purpose of the TPM path, protecting policy from tampering, was not enforced by the TPM. -The gateway is now measured into an NV extend index at startup, before it serves traffic, per P2 below. What is still missing is the signed binding: the index value travels as an ordinary NV read, which the quote signature does not cover, so a verifier cannot yet distinguish a genuine value from one a compromised gateway reported. `TPM2_NV_Certify` closes that, and until it does the measurement is a local integrity control rather than remote-verifiable evidence. +Both halves are now closed. The gateway is measured into an NV extend index at startup, before it serves traffic, and the value is bound by two `TPM2_NV_Certify` calls bracketing the extend, so both the pre and post values are TPM-signed and a verifier checks `post == H(pre || expected_gateway_digest)` with nothing collector-asserted and no verifier-side state. See P2 in section 5 for the construction and why one certify is not enough. Both were validated on an Azure Trusted Launch vTPM on 2026-08-01; the certify run found two defects in already-merged code, recorded in [hardware-validation.md](../testing/hardware-validation.md). + +What the appraisal does not give you: it proves the gateway extended a specific digest into a specific index, not that the digest corresponds to known-good code, unless the relying party supplies an expected value. Key provenance for the certifying key also remains host-dependent on Azure, per section 7. ### 4.4 PCR digests are uninterpretable (#433) @@ -124,13 +126,13 @@ cMCP should describe three tiers rather than one attestation story. |---|---|---| | Confidential compute (TDX, SEV-SNP) | Vendor-signed report, memory encrypted | Host operator and local code execution | | TPM, target model in section 5 | AK-signed quote chained to a vendor CA, gateway measured into a non-resettable index, signing key sealed to that policy | Local code execution below the gateway measurement | -| TPM, as implemented today | AK-signed quote over PCRs 0-7, gateway measured into a `TPM_NT_EXTEND` NV index, AK chain verified to a pinned root **where the host's AK certificate permits it** | Remote and passive adversaries, accidental drift, and forgery of the gateway measurement | +| TPM, as implemented today | AK-signed quote over PCRs 0-7, gateway measured into a `TPM_NT_EXTEND` NV index and bound by a bracketing `TPM2_NV_Certify` pair, AK chain verified to a pinned root **where the host's AK certificate permits it** | Remote and passive adversaries, accidental drift, and forgery of the gateway measurement | Two things about row three, both load-bearing and neither visible in a one-line summary. **Chain verification is host-dependent, not a given.** Azure Trusted Launch runs two vTPM CA hierarchies concurrently: one chains to the root pinned in `cmcp_verify/tpm_roots.py` over AIA, the other carries no AIA extension at all and therefore cannot produce a chain. On the latter, evidence is signed and fresh but proves nothing about *where* the key lives, so key provenance is unavailable. See [hardware-validation.md](../testing/hardware-validation.md) and #453. -**The gateway measurement is not yet signed evidence.** The NV index value travels as an ordinary read that no signature covers, so it is a local integrity control rather than something a relying party can appraise. `TPM2_NV_Certify` closes that and is the open half of #432. +**The gateway measurement is signed evidence, but it does not carry known-good.** The NV index value is bound by a bracketing `TPM2_NV_Certify` pair, so a relying party can appraise it rather than take the collector's word for it (#432, closed, hardware-validated 2026-08-01). What the pair proves is that this gateway extended this digest. Whether that digest is the right one is a release-engineering question: without an expected value supplied by the relying party, the appraisal reports the pair as internally consistent and says so, rather than implying more. The gap between rows two and three is the remaining work in section 5. Naming it plainly is more useful to anyone evaluating the project than any claim we could round up to. diff --git a/docs/spec/transport.md b/docs/spec/transport.md index 952661c0..26a9e1a8 100644 --- a/docs/spec/transport.md +++ b/docs/spec/transport.md @@ -90,6 +90,15 @@ Agent **Decision for Phase 1**: stdio transport is unsupported. Agents using stdio-only MCP servers must either migrate those servers to HTTP/SSE or use Option B as an undocumented workaround at their own risk. +> **Under review (2026-08-09).** Both options above share an assumption that does not hold +> once cMCP is in the path: that the *agent* spawns the MCP server. It does not — this +> document's own agent-configuration section states that the agent reaches only the gateway +> and that the runtime catalog is authoritative. [`stdio-transport.md`](stdio-transport.md) +> proposes the gateway spawning the server as its own child *inside* the enclave, which +> introduces no component outside the TEE and makes the server binary measurable before it +> runs. It also states what that costs, which is that the server then executes inside the +> same isolation domain as the policy evaluator. + --- ## "Zero Code Changes" Claim: Precise Scope