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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **stdio transport: the gateway spawns the MCP server as its own child, inside the enclave (#484).** `docs/spec/transport.md` ruled stdio out and rejected two bridging options, correctly, because both put a translating component *outside* the TEE where it can inject or suppress calls before the gateway sees them. Both options assumed the agent spawns the server. It does not: that same document states the agent reaches only the gateway and that the runtime catalog is authoritative. So the child is a child of a process already inside the enclave, and nothing crosses the boundary that does not cross it today.

**This buys a binding no network upstream can have.** The gateway chooses when to `exec`, so it digests the entrypoint first and refuses to spawn on a mismatch. A pinned TLS fingerprint identifies an endpoint; a verified digest identifies code.

**And it costs something, which is in the spec rather than the footnotes.** 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 process spawned later. The child's digest is therefore reported as its own evidence class, `spawn-measured` or `spawn-unmeasured`, never folded into `hardware_attestation`.

Defaults: `attestation.allow_unmeasured_spawn` is `false`, so a server the catalog does not pin is not spawned at all. Turning it on does not make the spawn silent; every such call is recorded as `spawn-unmeasured`.

Two design corrections came out of writing the tests. **Pinning the executable is nearly 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 match a completely different server; `measure_target` pins the entrypoint instead. And **executable and readable are different properties**: a Windows Store Python is an App Execution Alias that runs fine and cannot be opened for reading, so an unmeasurable target is a refusal with an explanation rather than a crash.

Framing errors are fatal by design. 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 its request is fatal for the same reason. stderr never enters the audit chain, because diagnostics carry payloads and the chain is meant to be shareable: content goes to the logger, only the byte count is exposed.

The catalog schema conditions its requirements on transport rather than relaxing them, so a network transport still requires `url` and `tls_fingerprint` while stdio requires `spawn`.

## [0.4.0] - 2026-08-08

**Anyone running 0.3.0 should upgrade.** On 0.3.0 a forged TPM quote was reported as hardware-attested: the `tpm2` branch of `verify_trace_claim` called only `verify_tpm_measurement`, which takes no signature parameter, so a `TPMS_ATTEST` with the correct magic and matching `qualifying_data` passed with no signature and no certificate chain (#370). The authenticated path existed and was tested; nothing in production called it. This release makes the quote signature and the AK certificate chain the gate on `hardware_attestation`, so evidence that does not chain to a pinned root can no longer report as verified.
Expand Down
4 changes: 3 additions & 1 deletion STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ picture is stated once. Developer Preview: interfaces may change before v1.0.
| `attestation.staleness_policy` | `fail_closed` |
| `attestation.validity_seconds` | `86400` |
| `policy_reload_interval_seconds` | `0` (disabled; policy change requires an enclave restart) |
| `attestation.allow_unmeasured_spawn` | `false` (a stdio server the catalog does not pin is not spawned) |

## Capabilities

| Capability | Status | Notes |
|---|---|---|
| MCP interception + Cedar policy evaluation inside the TEE | Shipped | HTTP/SSE transport. `stdio` is not yet supported (bridge planned, Phase 2). |
| MCP interception + Cedar policy evaluation inside the TEE | Shipped | HTTP/SSE transport. |
| `stdio` transport | Shipped | The gateway spawns the server as its own child **inside the enclave**, rather than bridging from outside, which is what the two rejected options in [`docs/spec/transport.md`](docs/spec/transport.md) did. It measures the entrypoint against the catalog and refuses to spawn on a mismatch, so a stdio upstream carries a stronger binding than a network one: a TLS pin identifies an endpoint, a digest identifies code. The cost is real and stated in [`docs/spec/stdio-transport.md`](docs/spec/stdio-transport.md): the server then runs in the same isolation domain as the policy evaluator, and the gateway's launch measurement does not cover a child spawned later. Recorded as `spawn-measured`, or `spawn-unmeasured` when `attestation.allow_unmeasured_spawn` is on. |
| Enforcement modes (`enforcing` / `advisory` / `silent`) | Shipped | Default is `enforcing`. |
| Hash-chained audit log, TEE-sealed signing key | Shipped | |
| `GatewayClaim` (TRACE Claim) generation + signing | Shipped | Normative schema: [`schemas/trace-claim.schema.json`](schemas/trace-claim.schema.json). |
Expand Down
124 changes: 114 additions & 10 deletions schemas/catalog-entry.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
"additionalProperties": false,
"required": [
"display_name",
"url",
"tls_fingerprint",
"transport"
],
"properties": {
Expand All @@ -43,21 +41,109 @@
"description": "Colon-separated SHA-256 TLS certificate fingerprint in OpenSSL format."
},
"spiffe_id": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"pattern": "^spiffe://",
"description": "SPIFFE SVID for this server; required when SPIFFE is deployed, null otherwise."
},
"transport": {
"type": "string",
"enum": ["http-sse", "websocket"],
"description": "Transport protocol; stdio is excluded from Phase 1."
"enum": [
"http-sse",
"websocket",
"stdio"
],
"description": "Transport protocol. stdio means the gateway spawns the server as its own child inside the enclave; see docs/spec/stdio-transport.md."
},
"spawn": {
"type": "object",
"additionalProperties": false,
"required": [
"command"
],
"description": "How to start a stdio server. Required when transport is stdio, ignored otherwise.",
"properties": {
"command": {
"type": "string",
"description": "Executable to run."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "Arguments."
},
"binary_digest": {
"type": [
"string",
"null"
],
"pattern": "^sha256:[0-9a-f]{64}$",
"description": "Expected digest of measure_target. Absent means the server is not pinned, which requires attestation.allow_unmeasured_spawn and is recorded as spawn-unmeasured."
},
"measure_target": {
"type": [
"string",
"null"
],
"description": "File the digest covers; defaults to the resolved executable. Set this to the entrypoint for interpreted servers, where the executable is the interpreter and every server on the host would otherwise share one digest."
}
}
}
}
},
"allOf": [
{
"$comment": "A network transport needs an endpoint and a pin; stdio has neither.",
"if": {
"properties": {
"transport": {
"enum": [
"http-sse",
"websocket"
]
}
},
"required": [
"transport"
]
},
"then": {
"required": [
"url",
"tls_fingerprint"
]
}
},
{
"$comment": "A stdio server needs to say what the gateway should spawn.",
"if": {
"properties": {
"transport": {
"const": "stdio"
}
},
"required": [
"transport"
]
},
"then": {
"required": [
"spawn"
]
}
}
]
},
"approved_definition": {
"type": "object",
"additionalProperties": false,
"required": ["description", "input_schema"],
"required": [
"description",
"input_schema"
],
"properties": {
"description": {
"type": "string",
Expand All @@ -68,7 +154,10 @@
"description": "JSON Schema describing the tool input parameters."
},
"output_schema": {
"type": ["object", "null"],
"type": [
"object",
"null"
],
"description": "JSON Schema describing the tool output; null if not specified."
}
}
Expand All @@ -80,7 +169,15 @@
},
"compliance_domain": {
"type": "string",
"enum": ["hipaa_phi", "pci_data", "mnpi", "pii", "internal", "external", "public"],
"enum": [
"hipaa_phi",
"pci_data",
"mnpi",
"pii",
"internal",
"external",
"public"
],
"description": "Compliance classification domain for this tool."
},
"requires_baa": {
Expand All @@ -90,7 +187,14 @@
},
"sensitivity_level": {
"type": "string",
"enum": ["public", "pii", "confidential", "hipaa_phi", "mnpi", "trade_secret"],
"enum": [
"public",
"pii",
"confidential",
"hipaa_phi",
"mnpi",
"trade_secret"
],
"default": "public",
"description": "Data sensitivity level of information this tool accesses or returns."
},
Expand Down
31 changes: 28 additions & 3 deletions src/cmcp_runtime/catalog/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ConfigError,
ToolNotInCatalog,
)
from cmcp_runtime.mcp.stdio import StdioSpawn

_CATALOG_ENTRY_SCHEMA_PATH = Path(__file__).parent.parent.parent.parent / "schemas" / "catalog-entry.schema.json"

Expand All @@ -27,8 +28,18 @@ class ServerIdentity:
url: str
tls_fingerprint: str
spiffe_id: str | None
transport: str # "http-sse" or "websocket"
transport: str # "http-sse", "websocket", or "stdio"
rotation_mode: str # "key-pinned" (default) or "cert-pinned"
spawn: StdioSpawn | None = None
"""How to start a stdio server. Present only when transport is "stdio".

A stdio server has no URL and no TLS certificate, so ``url`` and
``tls_fingerprint`` are empty for one and the schema does not require them.
"""

@property
def is_stdio(self) -> bool:
return self.transport == "stdio"


@dataclass
Expand Down Expand Up @@ -175,13 +186,27 @@ def load_catalog(catalog_path: str, expected_hash: str | None = None) -> ToolCat
)

raw_server = raw["server"]
raw_spawn = raw_server.get("spawn")
spawn = (
StdioSpawn(
command=raw_spawn["command"],
args=tuple(raw_spawn.get("args", ())),
binary_digest=raw_spawn.get("binary_digest"),
measure_target=raw_spawn.get("measure_target"),
)
if raw_spawn
else None
)
server = ServerIdentity(
display_name=raw_server["display_name"],
url=raw_server["url"],
tls_fingerprint=raw_server["tls_fingerprint"],
# Empty rather than absent for a stdio server: it has no endpoint,
# and inventing a URL for one would put a fiction in the audit chain.
url=raw_server.get("url", ""),
tls_fingerprint=raw_server.get("tls_fingerprint", ""),
spiffe_id=raw_server.get("spiffe_id"),
transport=raw_server.get("transport", "http-sse"),
rotation_mode=raw_server.get("rotation_mode", "key-pinned"),
spawn=spawn,
)

raw_def = raw["approved_definition"]
Expand Down
14 changes: 14 additions & 0 deletions src/cmcp_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ class AttestationConfig:
validity_seconds: int = 86400
staleness_policy: StalenessPolicy = StalenessPolicy.FAIL_CLOSED
expected_measurement: str | None = None
allow_unmeasured_spawn: bool = False
"""Permit spawning a stdio server whose binary the catalog does not pin.

Default off. A child runs inside the enclave, in the same isolation domain as
the policy evaluator and the audit chain, and the control that pays for that
is refusing to spawn what cannot be checked. Turning this on does not make
the spawn silent: every such call is recorded as ``spawn-unmeasured``.
"""


@dataclass
Expand Down Expand Up @@ -101,6 +109,7 @@ class Config:
"validity_seconds",
"staleness_policy",
"expected_measurement",
"allow_unmeasured_spawn",
}
_KNOWN_AGENT_MANIFEST_KEYS = {"path", "trust_anchor_path", "authenticated_subject"}

Expand Down Expand Up @@ -264,6 +273,10 @@ def load_config(path: str) -> Config:
if expected_measurement is not None and not isinstance(expected_measurement, str):
raise ConfigError("attestation.expected_measurement must be a string")

allow_unmeasured_spawn = attest_raw.get("allow_unmeasured_spawn", False)
if not isinstance(allow_unmeasured_spawn, bool):
raise ConfigError("attestation.allow_unmeasured_spawn must be a boolean")

max_bytes = raw.get("max_response_size_bytes", 2 * 1024 * 1024)
if not isinstance(max_bytes, int) or max_bytes <= 0:
raise ConfigError("max_response_size_bytes must be a positive integer")
Expand Down Expand Up @@ -324,6 +337,7 @@ def load_config(path: str) -> Config:
validity_seconds=validity_seconds,
staleness_policy=staleness_policy,
expected_measurement=expected_measurement,
allow_unmeasured_spawn=allow_unmeasured_spawn,
),
agent_manifest=AgentManifestConfig(
path=agent_manifest_path,
Expand Down
Loading