Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/spec/stdio-transport.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions docs/spec/tpm-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/spec/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down