# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Security - **The operator token was measured.** Only the bearer token was excluded from the measured configuration, so `CMCP_OPERATOR_TOKEN` fed the gateway digest (since #629): rotating it changed the digest, and a digest over it reached the measurement index and, on `sev-snp`, `tdx` and `azure-cvm-sev-snp`, the report nonce. A verifier recomputing an expected digest also needed the production token. The configuration digest changes once for every deployment, since the field leaves the measured payload. A deployment that used a low-entropy operator token should rotate it after upgrading. - Agent Manifest v0.2: the SDK appraised the COSE envelope, but the binding fields were read from the decoded dict the caller passed, and nothing checked that the two were the same document. A valid envelope paired with a dict carrying another policy or catalog hash bound. The dict must now equal the signed payload, and `issuer_key_id` is the key that verified the envelope instead of an empty string. ### Added - `build_server(ctx, trace_gate=...)`: an optional pre-transport hook for verifier-issued TRACE tokens (`cmcp_runtime.trace_gate.TraceGate`). With a gate, `POST /trace/challenge` and `POST /trace/admit` are registered behind bearer auth, every `tools/call` needs a holder proof bound to its exact action, and the gate is rechecked and a receipt committed before any byte goes upstream. A gate requires enforcing mode. With no gate, the routes do not exist and the call path is unchanged. - `cmcp_runtime.manifest_catalog.manifest_catalog_binding`: the Agent Manifest tool Merkle root (spec 3.2.3) computed from the catalog being served, kept separate from cMCP's sealed catalog digest. - Experimental `evidence-requirements-experimental-v1` manifest profile: the tool binding is checked against that Merkle projection, and the signed evidence requirements must also name the sealed catalog digest. ### Removed - The `opaque` attestation provider and its verifier. `cmcp_runtime.tee.opaque` was a placeholder that only raised, and `cmcp_verify.opaque` marked `hardware_attestation` verified on an unsigned `verified: true` from a remote endpoint, which is not evidence. Gone with them: `TEEProvider.OPAQUE`, the `ATTESTATION_PROVIDER_NOT_IMPLEMENTED` error, the `opaque` and `opaque-managed` platform branches in `verify_trace_claim`, and the `CMCP_OPAQUE_ATTESTATION_ENDPOINT` and `OPAQUE_API_KEY` variables. A config naming `attestation.provider: opaque` now fails with the same `ConfigError` as any unknown provider. The spec no longer lists a `highest` value for `attestation_assurance`, which only that provider used. ### Changed - **Breaking:** an Agent Manifest binding now requires issuer key authorization (Agent Manifest spec §5.3.3). The trust anchor at `agent_manifest.trust_anchor_path` carries an `issuers` list of SPIFFE URIs per key, and the gateway passes it to the SDK as `trusted_key_issuers`. Before, cMCP passed no issuer policy and agent-manifest 0.15.0 skipped the check, so any trusted key could sign a manifest naming any issuer. Startup now stops with `AGENT_MANIFEST_BINDING_FAILED` when no key in the trust anchor lists `issuers`, and when the signing key is not authorized for the manifest's `issuer`. `cmcp verify` and `verify_trace_claim` read the same policy (new `trusted_agent_manifest_key_issuers` argument) and report the binding unverified without it. The trust anchor is parsed once for keys and policy, and loading it fails on a malformed SPIFFE ID in `issuers` or a repeated `key_id`. Found by @litzki-systems on agentrust-io/agent-manifest#493. - Test fixtures and docs use vendor-neutral example model names. - LICENSE and NOTICE name the copyright holder as AgenTrust Contributors. The previous LICENSE line credited Agentic AI Foundation contributors, but the foundation has not accepted the project. CHARTER.md and GOVERNANCE.md now say the "cMCP" and "cMCP-compatible" marks are held by the founding maintainer until a host organization accepts the project. ### Fixed - An Agent Manifest that lists its tools in `tool_manifest.tools` could never bind: cMCP handed the SDK its sealed catalog digest, and from agent-manifest 0.13 the SDK compares `catalog_hash` with the Merkle root of that list. Such a manifest now binds when its list and root equal the projection of the catalog being served (`cmcp_runtime.manifest_catalog`). A manifest that omits `tools` is still compared with the sealed digest. `verify_agent_manifest_binding` takes `runtime_catalog` for this; without it a manifest with `tools` does not bind, which includes `cmcp verify`, since it has no catalog to project. - The Agent Manifest tool projection (`cmcp_runtime.manifest_catalog`, added in #713 after the v0.7.0 tag) now follows the restricted profile ruled on agentrust-io/agent-manifest#340. `schema_hash` is SHA-256 over the JCS form of `{"input_schema", "output_schema"}` instead of the input schema alone, `description_hash` is over the description as a JCS string instead of its raw UTF-8, and `endpoint_id` is the server's SPIFFE ID with no fallback to its URL. **This changes every `schema_hash`, `description_hash` and `catalog_hash` the projection produces**, so a manifest built from main before this fix must be reissued. No release carries the old values. Binding now joins the manifest's tools to the served catalog on exact `tool_name` plus `endpoint_id`, one to one, rebuilds the root from the manifest's `tool_id` and the served component hashes, and compares only that root. A non-empty served catalog with any server lacking a `spiffe_id` raises `ManifestCatalogOutOfProfile`, an outcome distinct from a failed join or a root mismatch; the gateway still does not bind. Reported by @asymx-mechanics (#719). ## [0.7.1] - 2026-10-19 ### Security - **A Cedar Allow reached with evaluation errors was enforced as Allow** (GHSA-frxc-7f57-6f4r). Cedar skips a policy whose condition errors, so a `forbid` reading a context attribute the current phase does not carry could not stop a matching `permit`. `CedarBackend` now denies any Allow that comes with evaluation errors, and the error reaches the `tool_call` or `egress_denied` audit row. So that a policy written for one phase does not deny in the other, both phases now carry the same context keys: egress adds `session_max_sensitivity` (the label string), `arguments`, `baa_covered`, `compliance_domain`, `server_identity`, `destination_class` and `attestation_platform`, and ingress adds `egress` (false), `sensitivity_level`, `injection_events`, `reset_count` and `response_size_bytes` (0). Without this the shipped BFSi demo denied every response at egress. A policy that reads a key outside that set, or `workflow_id` when the caller declared none, now denies. - **The TPM AIA certificate fetch was unbounded** (GHSA-mrcx-xvv4-m7f8). The chain walk followed redirects to any address and read responses without a limit. It now refuses destinations that do not resolve to global addresses, checks every redirect the same way, follows at most three and ignores responses over 64 KiB. Each hop's host is resolved once and the connection goes to the checked address (the request still names the host, and HTTPS still verifies the certificate against it), so a second DNS answer cannot point the fetch somewhere else. The fetch no longer uses environment proxies. An on-prem CA whose AIA host has a private address falls back to the vendored bundle. - **Upstream drift was classified against the wrong catalog entry** (GHSA-2p85-666x-xm7x). The scanner received the server of whichever entry was last in the catalog. Detection and fail-closed denial were not affected; only the classification written to the audit row was. ## [0.7.0] - 2026-09-30 ### Security - TPM 2.0: `verify_tpm_measurement` listed a `qualifying_data` mismatch as unverified but still returned `verified`, so a genuine quote could be attached to a claim signed by any key. It also never compared the quote's `pcrDigest` with the claim's `measurement`, though `docs/spec/tpm-security-model.md` said it did. Both now fail the check. ### Changed - `verify_trace_claim` takes `expected_launch_measurements`, and `cmcp verify` takes a repeatable `--launch-measurement`. A hardware claim whose measurement is outside the set fails. With no set, a hardware claim is `partially_verified` with `launch_measurement` unverified: genuine hardware says what ran, not that it was approved, and a report from any guest image used to come out `verified`. ## [0.6.0] - 2026-09-25 ### Added - `agent_manifest.revocation_list_path`: a JSON Lines revocation list in the Agent Manifest CLI's CRL format. Startup rejects a bound manifest listed there, and a missing or malformed list aborts startup instead of reading as empty. Without it, startup now logs that revocation is not checked; before, the SDK was always handed an empty store. ### Fixed - `verify_audit_bundle` now requires every change of entry `session_id` to be a recorded `session_reset` that names the closing session and its successor, and requires the claim's `gateway.session_id` to be the session the bundle ends in. A bundle mixing sessions with no recorded boundary used to verify. - `code_digest` hashes each installed file RECORD vouches for and refuses the measurement when the bytes differ, so an edit to an installed file with RECORD left alone no longer leaves the digest unchanged. A listed file that was deleted is marked `` in the digest. Digests of intact installs are unchanged. - `GET /audit/export` served the live chain under any `session_id` the caller named, labelling the bundle with that id. It now serves a closed session's chain under its own id and the live chain only under the current session id, and returns 404 otherwise. The quickstart read the internal id by exporting under the `_cmcp.session_id` label; it now reads it from the allowed call's `result._cmcp.session_id`. - A bearer token containing non-ASCII characters returned 500 from the auth middleware; it now returns 401. - The container image and the ClusterFuzzLite build install dependencies from the new hash-pinned `requirements/runtime.txt`, and the base image is pinned by digest. - **Exhaust upstream `tools/list` pagination before drift and provenance comparisons** (#631). HTTP and stdio share bounded acquisition: later-page failures, malformed or ambiguous listings, and cursor cycles are unchecked, never a comparison against a partial catalog. Existing drift policy and unchecked-call behavior are unchanged. Observed by solloek369-arch on #566 and confirmed in #631 by Imran Siddique. Drift and provenance now share one completed discovery acquisition per server/authority per session, including unchecked outcomes, avoiding a second full pagination walk on cold calls with provenance configured. Concurrent readers wait for completion; cancelled reads are not cached. Session rebinding resets acquisition and comparison caches together. The duplicate-fetch cost was identified by qubeena07 during review of #633. - `verify_audit_bundle()` now reports malformed `tool_name` values during claim summary verification and malformed external-receipt `evidence_type` values as failed verification results instead of raising `TypeError`. Hash-chain checks remain active, and receipt verification remains opt-in (#593, #597). - `verify_trace_claim()` stops at a schema failure and returns `CLAIM_MALFORMED` with the offending `malformed_field`, instead of continuing to signature and binding checks against a claim whose shape was never established (#592, #596). - Embodied action evidence nested inside embodied action evidence is rejected rather than verified recursively (#688). - The TRACE claim schema declares `intent_hash`, `agent_key_thumbprint` and `enforcement_mode` under `gateway.agent_identity`. The runtime already emitted them whenever an Agent Manifest binding was configured, so those claims failed the schema they were signed against (#622, #632). - A non-string value for a path setting in config is reported as a configuration error instead of raising a `TypeError` (#673, #674). - `docker-compose.yml` runs the gateway from `/var/lib/cmcp` on a named `gateway-data` volume, so audit data survives `docker compose down` (`down --volumes` still deletes it). CI checks the compose storage layout (#686). - Accept emitted `gateway.call_log_summary` and call-graph `edges_represent` fields in the TRACE claim schema. Both remain optional for older claims; malformed values and undeclared properties remain rejected. ### Security - **An unrecognised enforcement mode was signed as `advisory`.** `_build_policy` fell back to `advisory` for any value outside `enforcing`, `advisory` and `silent`, so a claim built from such a value asserted that the policy had been evaluated. The gateway's own path passes a validated `EnforcementMode`, so this was reachable only by callers building claims directly. Claim construction now refuses the value, as `_build_runtime` already refuses an unknown provider. Reported by @saintmalik in #654. - Add an opt-in exact-output disclosure contract and library adapter. Independently scoped owner signatures bind output bytes, source labels, workload, recipient, purpose, policy and validity. Durable pre-delivery consumption rejects replay and keeps failed delivery unknown. Existing gateway sink ceilings are unchanged; transport binding, classification, storage and clocks remain trusted inputs. - **Distinct tool names could share one Cedar policy identity.** The policy backend names a call's action by joining the underscore-separated parts of the tool name, so `read_file`, `read__file`, `_read_file` and `read_file_` all became `Action::"ReadFile"`, and a permit or forbid written for one applied to the others. The catalog loader checked only literal duplicates and uppercase names. It now refuses a catalog in which two entries derive the same action (`CATALOG_TOOL_NAME_COLLISION`), using the same derivation the backend uses, now shared in `cmcp_runtime.policy.action_name`. The derivation itself is unchanged, so existing policies keep their action names. Reported by @saintmalik in #655. - **Nothing in a claim showed whether the kill switch was armed, and a refusal left no evidence.** A claim with `kill_switch_triggered: false` read the same whether the switch was enabled and did not trip or was never enabled, and a call refused by a tripped gateway produced nothing a verifier could check. Claims from a gateway with the switch enabled now carry a `gateway.kill_switch` block with its settings and, when it tripped, `trigger` (`deny_rate` or `operator`); claims from gateways without it are unchanged. Each refused call returns a receipt signed with the claim signing key and bound to the closed session's claim by digest, and `cmcp_verify.verify_kill_switch_refusal` checks one against the other. `kill_switch.enabled` with no Agent Manifest now stops startup (`KILL_SWITCH_REQUIRES_IDENTITY`): the switch blocks an identity, and without one it stopped nothing while appearing armed. The tutorial no longer claims evidence is verifiable by any regulator or that no other gateway can produce it, and says what the evidence does not cover. - **The kill switch only acted when a session closed.** The deny rate was evaluated in `close_session`, so an agent the switch should have stopped kept calling for as long as its client kept the session open. Each call is now counted as it completes, and the call that crosses the threshold closes the session at once, signs its claim with `kill_switch_triggered: true`, and records `tripping_call_id`. Calls counted this way are not counted again at close. Operators can also trip the switch directly with `POST /kill-switch/trip`, which requires `reason` and `authorized_by`, blocks the bound identity, records the trip, and returns the closed session's claim. - **The kill switch did not survive a restart, and the close that tripped it failed.** Blocked identities were held in an in-memory set, so any restart lifted every block. Over HTTP, a close that tripped the switch raised `KillSwitchTripped` while creating the successor session: the caller got a generic `500 INTERNAL_ERROR` instead of the claim, and later tool calls waited on a rotation that could never finish instead of receiving the documented `403 KILL_SWITCH_TRIPPED`. Blocks now live in the audit database, the tripping close returns its signed claim, and every later call is refused at once. A gateway that starts with its identity blocked comes up halted rather than failing to start, and `/readyz` reports it. The tutorial described a `DELETE /admin/kill-switch/...` endpoint that did not exist; the operator route is now `POST /kill-switch/unblock`, which requires `agent_id`, `reason` and `authorized_by` and records the unblock in the audit chain. - **A compromised policy signing key could not be revoked without a restart.** Hot reload accepted any bundle signed by `CMCP_POLICY_SIGNING_KEY` with a higher version, and the only way to stop trusting that key was new config plus a restart, so until every gateway restarted the key's holder could keep installing policy on it. `CMCP_POLICY_SUCCESSOR_SIGNING_KEY` now pins a second key at startup. A statement in `signing-key-revocations.json`, signed by the successor or the current key, revokes the current key on the next reload and promotes the successor. The policy in force is then untrusted and every tool call is refused with `POLICY_SIGNING_KEY_REVOKED`, in every enforcement mode, until a successor-signed bundle with a higher version is installed. A bundle signed by the revoked key is refused with the same code. Revocation is one-way within the process: no statement un-revokes, a stolen current key cannot revoke the successor, and bad statements are logged (`POLICY_KEY_REVOCATION_INVALID`) and skipped without blocking the reload. TRACE claims gain an optional `gateway.policy_signing` object recording the key the policy in force verified under and the keys revoked. Startup also now reports a bundle signature failure as a `POLICY_SIGNATURE_INVALID` FATAL entry and exit 1, where the exception previously propagated out of `run_startup`. - **Signed policy reload never installed a changed bundle when a hash was also pinned.** `PolicyStore.reload_if_stale` passed the startup `CMCP_POLICY_HASH` to every reload, so with a hash and a signing key both pinned (the documented production shape, and the only one outside dev mode, where a hash is required) a correctly signed newer bundle failed with `PolicyHashMismatch` and the old policy stayed in force. With a key pinned, the key now authorises reloads and the hash is checked on the startup load. - Add optional operator-owned tool and caller-response sensitivity ceilings. They enforce accumulated/catalog/declared classification independently of Cedar mode, before discovery and dispatch and before response release. Strict sink policy suppresses captured stdio stderr content; upstream error logs no longer echo tool messages. This is conservative label enforcement, not semantic information-flow verification or automatic declassification. - Add verifier-owned SNP platform policy to native and Azure verification and the public TRACE verifier. Explicit requirements fail closed without signed SNP evidence. The optional policy covers PLATFORM_INFO, not guest DEBUG, firmware TCB currency, or runtime admission. - **A response arriving during an operator reset raised the successor session.** The per-session mutation lock serialised a reset and a response elevation but did not order them, so whichever coroutine acquired it second won. A response in flight when the reset landed was applied to the successor, which had just been initialised to `public`, and recorded the pre-reset `call_id` as the call that raised it. The successor exists to start at the minimum level, so this carried the closed session's sensitivity across the boundary the reset drew. `update_from_inspection()` now takes the `reset_count` observed at call entry and drops a response whose generation no longer matches, logging `SESSION_RESET_RACE`. The discriminator is `reset_count` rather than `session_id` because `upgrade_attestation()` rotates the identifier while deliberately continuing the same session, so a call in flight across an attestation upgrade must still apply. The previous concurrency test asserted only that `max_sensitivity` remained a member of `SENSITIVITY_ORDER`, which every value satisfies. - **The reset route accepted the tool-invocation token.** `POST /sessions/{id}/reset` is not reachable as an MCP tool, but it sat behind the same single `CMCP_BEARER_TOKEN` as `POST /mcp`, so an agent host holding its own tool-invocation credential could clear accumulated session sensitivity. The operator interface (session reset and catalog exception) now takes `CMCP_OPERATOR_TOKEN`, which must differ from `CMCP_BEARER_TOKEN` and is required outside `CMCP_DEV_MODE=1` (`OPERATOR_TOKEN_REQUIRED`). Where it is unset those routes still fall back to the bearer token, so an existing single-token deployment keeps working until it sets the new variable. - **Session close retained stdio children and per-session upstream caches.** (`#625`) `rebind_session()` rotated the audit chain and call logs but never called `aclose()`, so the stdio child, pooled HTTP clients, and the provenance/drift-checked caches all survived a close and were reused by the next session - the exact cross-session contamination `docs/spec/stdio-transport.md` names as the reason children are scoped to one session rather than pooled. Close now serializes session transitions, drains admitted calls before signing, cleans up resources before rebinding, and preserves claim and resource ownership across retryable failures. Partial claim failures keep admission sealed and are reported for operator investigation. A failed cancellation drain also keeps admission sealed until a close retry can drain the remaining work. Graceful shutdown rejects new work and resource acquisition, drains active calls, and coordinates spawning with cleanup; an incomplete drain is reported as failure. Concurrent first-use stdio spawning is serialized to avoid creating an untracked second child. Cancellation during session hydration is finalized before signing; a failed terminal audit write prevents signing or rotating an incomplete claim. `POST /sessions/{id}/reset` retires a session id and opens a successor, so it leaked the same resources for the same reason; it now drains admitted calls and releases them before recording the boundary, which also leaves a failed reset retryable with the session untouched. A reset naming an already-rotated session is rejected before draining, so it cannot cancel the successor's in-flight calls. A child that fails to close is retained for retry; a pooled HTTP client that fails to close is dropped and logged instead, because an `AsyncClient` marks itself closed and HTTPcore empties its pool before the streams are released, leaving nothing a retry could reach. A call arriving during a transition still waits for the successor, but the wait is bounded: a close that cannot resolve, such as one whose successor creation keeps failing, now answers callers with the reason instead of blocking them indefinitely. ### Added - A reference for confining an agent that already holds plaintext on native Linux (`docs/confinement.md`, `examples/confinement/`, #659). The agent runs in a Docker container with no network, a read-only root filesystem and no automatic restart, and reaches tools only through a host bridge to the gateway. A separate host watchdog stops the container on EOF or a missed two-second heartbeat. `tests/confinement/` carries the bypass harness and validates the claims the confined gateway emits (#663). - `ExecutionRegistry`, a standalone SQLite state machine for execution correlation (#565, #606). It is a foundation only: the gateway does not construct or admit through it, and no replay or exactly-once guarantee is provided in this release. See `docs/spec/execution-correlation.md`. - **Wire the configured session-state store into startup.** `session_state_path` is accepted at config load, checked for a non-blank string and traversal, and opened as a `SqliteSessionStateStore` before the gateway starts. An unopenable path aborts startup with `SESSION_STATE_STORE_UNAVAILABLE`. Writes for the same session identifier use SQLite's cross-process transaction lock. Restarting the gateway or sharing the database does not restore the same logical session. Authenticated session resumption remains separate work in #653; this change only connects the existing store. Unset preserves the in-process default. ### Changed - A `tools/call` that supplies `_cmcp.execution_id` is now refused with `execution_correlation_unavailable` before discovery or invocation, and a malformed value (including explicit null) with `execution_invalid_execution_id`. Omitting the field keeps the previous call path (#606). - The reset audit entry now identifies the session boundary rather than only the sensitivity transition: `detail` carries the closed session identifier, the successor identifier, the resulting reset counter, and which credential was verified. `detail` is inside the canonical body, so those fields are covered by the entry hash. - **The audit chain no longer attributes post-reset entries to the closed session.** `AuditChain.rotate_session_id()` moves attribution to the successor after the boundary entry is written, so the reset entry belongs to the session that reached the recorded value and later entries belong to the successor. Previously every entry after a reset carried the closed session's identifier and the successor's identifier appeared nowhere in the chain. - A reset now preserves the closed session's final state as a distinct `ClosedSessionRecord` instead of overwriting it, and `POST /sessions/{id}/reset` returns `closed_session_max_sensitivity` and `reset_count`. ## [0.5.0] - 2026-09-05 ### Security - **Cross-boundary compliance recording was dead for HIPAA PHI, PCI data and MNPI.** `call_log._HIGH_SENSITIVITY_DOMAINS`, which decides whether a call leaving a domain is recorded as a boundary crossing in the TRACE claim, was the literal `{"pii", "phi", "pci", "restricted"}`, while the catalog schema permitted `{hipaa_phi, pci_data, mnpi, pii, internal, external, public}`. The two overlapped on `pii` alone: `phi`, `pci` and `restricted` could never appear as a `compliance_domain`, and the three most regulated domains the field can express never matched. A session that read HIPAA PHI and then called an external tool recorded no crossing. The set is now derived from a single `COMPLIANCE_DOMAINS` vocabulary beside `SENSITIVITY_ORDER`; the legacy spellings stay in it so no deployment regresses. - **Policy bundle hash now uses RFC 8785** (GHSA-wh6r-6j4v-p4p6). `docs/spec/cedar-policy.md` §1 defines `canonical_json` as RFC 8785 and the implementation used `json.dumps(sort_keys=True, ensure_ascii=True)`. The two agree for ASCII-only, integer-only bundles and diverge on non-ASCII strings and float-typed numbers, so an independent implementation following the spec could not reproduce this gateway's startup gate. **Breaking for bundles carrying non-ASCII text or float-typed numbers:** those change hash and need re-pinning. ASCII-only bundles are byte-identical. - **`_redact_auth_headers` is deny-by-default.** It redacted only `Authorization`, while the same request is configured with `OPAQUE_API_KEY`, so a deployment carrying it in `x-api-key` or a cookie logged it in clear on the debug path. - **Least-privilege CI.** All 24 third-party action references pin a commit SHA rather than a mutable tag, including the release-path steps that hold registry credentials, signing keys and `id-token: write`. Four workflows gained a top-level `permissions:` floor, and four `${{ }}` interpolations moved out of `run:` blocks into `env:`. ### Added - **`cert-pinned` rotation mode is reachable.** `server.rotation_mode` has always been read by the loader and used by the proxy, and `docs/spec/tool-identity.md` documents `"rotation_mode": "cert-pinned"` as the catalog field an operator sets. The catalog schema declared the `server` block `additionalProperties: false` and never listed it, so a catalog following the documentation was rejected at load and every deployment ran the weaker `key-pinned` default with no way to opt out. - **`compliance_domain` is deployment extensible.** It was a closed seven-value enum, so a deployment with its own classification could not express it and the catalog would not load. Validation moves to load time against the built-in vocabulary plus `sensitivity.compliance_domains` in config, mirroring what `sensitivity_level` already does since #479. A deployment adding a domain declares whether it is regulated. Additive only: a config key colliding with a built-in is rejected. - `rfc8785` is a declared dependency rather than a transitive one. ### Fixed - TLS pinning test fixtures set `minimum_version = TLSv1_2`; the server was built with `PROTOCOL_TLS_SERVER` and no floor, leaving TLSv1 and TLSv1.1 reachable in the test that asserts the gateway's transport rules. ### Security - Bind SNP, Azure CVM and TDX evidence to the claim's existing `trace.runtime.nonce` (#595). These branches previously read a `report_data` field forbidden by the runtime schema, passing `None` to an optional binding check. A valid report for a different key or audit root could therefore receive hardware-verification credit on the SNP and Azure paths. The verifier now requires the canonical 64-byte nonce and checks it against the report (or the AK-signed quote on Azure). TDX TDREPORT-only claims remain partially verified; this does not add DCAP quote collection or transport. ## [0.4.1] - 2026-09-02 **Anyone running 0.4.0 should upgrade.** On 0.4.0 `verify_gateway_measurement()` could return `verified=True` for a correctly signed TPM2_NV_Certify pair that does not refer to cMCP's configured `TPM_NT_EXTEND` object or its complete 32-byte range (GHSA-943q-hvhp-mrx2). The verifier compared the two signed TPM Names only with each other and never against trusted verifier policy, so an accepted AK could certify attacker-arranged values from an ordinary NV object and receive an authorization-grade verdict. No signature forgery was required. Reported and fixed by [Noah Ingwers](https://github.com/noah-ing). The appraisal API is intentionally breaking: callers that omit the full `GatewayNvAppraisalPolicy` now fail closed. ### Changed - TPM NV-certify wire parsing now delegates to Agent Manifest 0.11.2 instead of maintaining a second `TPMS_ATTEST`/`TPMS_NV_CERTIFY_INFO` parser in cMCP. The public `parse_nv_certify` return shape and `ValueError` contract remain intact. The shared parser also makes cMCP accept valid size-prefixed `TPM2B_ATTEST` transport framing, reject undeclared bytes after `nvContents`, and verify AK signatures over the canonical inner `TPMS_ATTEST` rather than over the transport length prefix. A genuine swtpm-produced two-certify reference pair exercises Agent Manifest's configured-root chain helper plus the signature, transcript, explicitly authorized test Name/range, and extend-relation path. The synthetic fixture CA is test trust only and makes no full-PKIX, hardware-provenance, or vendor-enrollment claim. - Removed AGT/`agent_os` from the production dependency graph. cMCP now owns the runtime call and response enforcement path; AGT remains isolated to CI and release governance verification. Legacy `agent_os_version` bundle metadata is still readable but is no longer enforced. ### Fixed - **Gateway NV appraisal accepted an evidence-selected NV object and certified range as the configured gateway measurement.** `verify_gateway_measurement` authenticated the AK chain, both signatures, phase bindings, and `post = SHA256(pre || digest)`, but only required the two signed Names to equal each other. It did not compare either Name or signed offset/extent with trusted verifier policy, and a collector-claimed digest could produce `verified=True` when no expected digest was supplied. An accepted AK could therefore certify attacker-arranged values from an ordinary or otherwise unapproved NV object and receive an authorization-grade verdict without proving use of cMCP's configured `TPM_NT_EXTEND` public template. Appraisal now requires a complete `GatewayNvAppraisalPolicy` supplied by the verifier: the exact signed TPM Name, offset zero, 32-byte extent, and expected gateway digest. Both attestations and the envelope copy must match that policy; digest-only legacy calls fail closed. The producer also validates the complete public area and TPM-returned Name before any read, extend, or certify; a newly defined object is checked in its unwritten state and again after `WRITTEN` is set. Unexpected existing objects are never silently redefined, and only an actual `TPM_RC_NV_UNINITIALIZED` read maps to the initial zero value. The API change is intentionally breaking for callers that omitted the full policy. A genuine swtpm corpus covers the canonical production object, a correctly signed same-handle ordinary object with an arranged byte relation, and a correctly signed partial range. This fixes the standalone primitive; the startup pair is still not carried by the TRACE schema or appraised by `verify_trace_claim`, so it is not an end-to-end runtime-claim guarantee. **`CMCPProxy._client_for_upstream` pooled every unpinned upstream server in a session behind one shared `httpx.AsyncClient` (#281).** The pinned branch already keys its cached client on the fingerprint (a matching pin *is* the same verified peer, so sharing there is correct), but both unpinned branches plain `http://` and the `PLACEHOLDER_FINGERPRINT` dev-mode pin collapsed to the single literal key `"unpinned"`, regardless of which server the entry actually pointed at. A gateway session whose catalog lists two or more unrelated unpinned upstreams (the supported, if discouraged, dev/demo path this same method's docstring describes) got one `httpx.AsyncClient` for all of them: one cookie jar, and one shared pool of `max_connections`/`max_keepalive_connections`, across servers whose only thing in common was that neither presented a real pin. - **`POST /mcp` `tools/call` 500'd on a non-string `name`, and silently accepted a non-object `arguments`.** `_handle_tool_call` read `tool_name: str = params.get("name", "").lower()` and `arguments: dict[str, Any] = params.get("arguments", {})`: the `.get(field, default)` default only covers a genuinely *absent* field, so a caller-supplied `name` that is present but not a string (an int, a list, a bool, `null`) reached `.lower()` and raised an unhandled `AttributeError`, caught only by the outermost `_unhandled_error_handler` and logged as `UNHANDLED_EXCEPTION`/`INTERNAL_ERROR` for what is ordinary client input validation, not an internal failure. `arguments` had the matching gap on the other side: `_arg_shape_violation` (the #518/#562 depth/key-count/string-length gate) only recognizes `dict`, `list` and `str`, so a scalar `arguments` (an int, for instance) silently returned "no violation" and reached `call_tool` with a shape its own type annotation says cannot occur. Both fields are exactly as caller-controlled as `_cmcp` a few lines below, already guarded with "A malformed `_cmcp` (string, list, number) must not 500 the call path" `name` and `arguments` were the two places that same reasoning wasn't applied. Both now return the same `-32602 Invalid params` JSON-RPC error the adjacent depth/key/string-length and non-dict-`params` checks already return, before `call_tool` is ever reached. - **On SEV-SNP, TDX and Azure CVM the policy actually in force was committed to nothing (#552, follow-up to #432).** `gateway_measurement()` folds the installed code, the policy bundle and the effective configuration into one digest, and #432 extended it into a `TPM_NT_EXTEND` NV index and had the TPM certify it. That path is validated on real Azure Trusted Launch vTPM hardware, and it ran for the `tpm` provider only: `_measure_gateway` returned early for every other provider. The stated reason was that "SEV-SNP and TDX commit their own binding through the report's fields". That is true and it is not equivalent. Those fields carry the **launch** measurement, which is fixed at boot and does not move when the Cedar bundle reloads mid-session through `PolicyEvaluator._maybe_reload()`. So on exactly the platforms whose whole premise is hardware-rooted policy enforcement, nothing signed said which policy was running. No new commitment scheme was invented. `make_measurement_bound_nonce(tee_public_key, measurement_digest)` puts the already-validated digest into the second half of the attestation nonce, in the same 64-byte layout `make_audit_bound_nonce` already uses: `jwk_thumbprint(pubkey) (32) || measurement_digest (32)`. `gateway_measurement().digest` is a raw 32-byte SHA-256, so it drops in unreshaped and a verifier compares it against a digest it recomputes rather than against a hash of one. The `tpm` provider is deliberately not in that report-data set: it has a separate NV-extend/certify path whose evidence remains startup-scoped, is not refreshed on policy reload, and is not carried by ordinary TRACE claims. This replaces the 32 random salt bytes on those providers, and freshness survives the change for a reason worth stating rather than assuming: the gateway generates a new signing key on every start, so `report_data[:32]` still differs between two starts of byte-identical code, policy and config. `report_data` carries one value and no history, so a report built before a bundle reload still looks well-formed on its own, and nothing in it says whether it is current. The gateway now re-attests on **every** policy-bundle reload, including one that finds the bundle unchanged, wired from `PolicyEvaluator._maybe_reload` through a new `on_reload` hook to `refresh_measurement_binding`. Re-signing an unchanged digest is not redundant: the same digest signed now and that digest signed an hour ago are different assertions, and only the latest report reaches a verifier. The cost is bounded by `policy_reload_interval_seconds` rather than by request rate, because `PolicyStore.reload_if_stale` stamps its clock before the attempt, and it is zero in the default configuration where reloading is off. A refresh that cannot re-attest logs and keeps the previous report rather than refusing traffic: the stale binding no longer matches the recomputed digest, so a verifier rejects the claim, and failing closed here would trade a detectable weakness for an outage. That is the same trade AUDIT-006 already makes for a failed per-session attestation. Recompute-and-compare is what catches a gateway that did not refresh, so it is now a check rather than a note in the spec. `verify_trace_claim` gains an optional `expected_gateway_measurement` (raw 32 bytes, hex, or `sha256:`-prefixed) and a `MEASUREMENT_NOT_BOUND` failure reason. It is opt-in because the expected digest must be an out-of-band trust input like `ApprovedHashes`: a check that read it out of the claim would be asking the claim to vouch for itself. The digest is compared directly rather than re-hashed, which is the one way it differs from the AUDIT-006 check beside it. **Known limit, stated rather than implied.** The binding is on the gateway's **startup** report. A TRACE Claim for a session carries the per-session report when one was produced, and AUDIT-006 already commits the audit-chain root in the same `report_data[32:64]`. So on the normal session path a verifier sees the chain-root commitment, not the measurement, and step 7c applies to claims that fall back to the startup report. Carrying both in one 64-byte field is not possible as the layout stands, and #552 explicitly scopes the audit-chain-root binding out, so this is left for the issue that takes that on. **Behaviour change:** an unmeasurable gateway on `sev-snp`, `tdx` or `azure-cvm-sev-snp` is now fatal at startup in production, as it has been on `tpm` since #432, because extending the measurement to a platform extends the consequence of not having one. `CMCP_DEV_MODE=1` still downgrades it to a warning, which is what an editable install with no `RECORD` metadata needs. Not addressed here, per the issue: binding the audit-chain root, which is session activity rather than gateway identity and which AUDIT-006 already owns in the per-session report's `report_data[32:64]`, and validation on real SEV-SNP and TDX silicon. The contract lands with software-only proof: the round trip through `SoftwareOnlyProvider` composes the nonce, takes the report, and has an independent recompute match both halves, with a stale pre-reload measurement correctly rejected. - **The public chained TPM quote verifier discarded the signature scheme and digest after parsing them (agent-manifest#320).** Passing only the bare signature made the shared verifier apply its legacy RSASSA/SHA-256 defaults: a valid RSAPSS/SHA-384 quote failed, while false scheme or digest metadata was not authoritative. `verify_tpm_quote_chained` now forwards the complete `ParsedSignature`; the existing Azure RSASSA/SHA-256 vector remains unchanged. This helper currently has no runtime caller, so the fix corrects the public API and its future reuse rather than changing today's TRACE claim-verification path. - **An approval record stopped verifying once its approvals expired (#533, follow-up to #531).** `verify_catalog_change` judged `approved_at` and `expires_at` against `time.time()`, so a record that was valid when the catalog was approved became permanently unverifiable, and the chain #517 exists to let an auditor replay could not be replayed. That made the record an authorization token with a lifetime rather than a provenance record. The interval is now what it says it is: an assertion about when the signature could have been produced. The caller passes `validity_instant`, a pinned checkpoint or transparency-receipt timestamp where it has one, and where it passes nothing each approval is judged at its own `approved_at`. Requiring a pin instead would mean an auditor cannot verify a record without also holding the pin, which is a worse default than the one it replaces. The `now` parameter is gone with the wall clock, and `time` is no longer imported. Revocation is unchanged and stays at verification time, with the reason stated in the code as well as the doc: whether a key was valid when it signed and whether it is revoked now are different questions, and they will read as the same check to whoever touches this next. - **The catalog-approval schema shipped in the repository and nowhere else, so nothing validated against it (#533, follow-up to #531).** `schemas/catalog-approval.schema.json` was added by #519 and never loaded: `verify_catalog_change` reimplemented structural validation by hand, the two drifted, and the schema was absent from `[tool.hatch.build.targets.wheel.force-include]` so an installed wheel did not carry it at all. #531 could only document one divergence, a negative `approved_at` that the schema rejects and the verifier accepted, as a strict xfail. The schema is now the structural authority. `verify_catalog_change` loads it and validates the record before any other check, and refuses to verify when it is missing from the installation, exactly as `loader.py` refuses to load a catalog without `catalog-entry.schema.json`. The hand-written checks that duplicated it are deleted rather than left unreachable: field presence and unknown members, digest shapes, integer and boolean types, string emptiness, and the signature alphabet. What stays in code is what JSON Schema cannot express, the runtime hash binding, the policy pin, reviewer identity and key rules, the validity interval ordering, and the signatures. The xfail is gone because the case now fails closed. The schema is force-included in the wheel alongside the catalog entry schema, and `scripts/verify_python_distribution.py` checks both resolve to a file inside the installed distribution, so a wheel that drops one fails the release smoke test rather than a verifier at runtime. A unit test asserts the force-include mapping, since that line is the only thing putting these files next to the code. `previous_record_hash` on the first record in a chain is the all-zero digest, `sha256:` followed by 64 zeros, stated in both the schema and the spec doc. It validated against the digest pattern before and meant nothing, which is the worst combination: representable and unspecified. - **`cmcp verify` could not authenticate a TPM attestation-key certificate chain.** The verifier library already required a caller-supplied `trusted_tpm_ca_pem`, but the CLI had no way to provide it, so otherwise complete TPM 2.0 evidence remained partially verified. `--trusted-tpm-ca` now accepts a verifier-owned PEM certificate bundle, validates it before verification, and passes it only to the TPM trust input. It does not configure AMD SEV-SNP or Intel TDX trust anchors. - **A single trusted reviewer key could approve any catalog change, under any policy (#517, follow-up to #519).** `verify_catalog_change` read `threshold`, `distinct_principals`, and `distinct_roles` out of the record it was verifying, and checked `policy_hash` for digest shape only: never recomputed over the policy body, never compared to anything. A record declaring `threshold: 1` with arbitrary bytes in `policy_hash` verified on one signature, so the M-of-N property the module advertises was unenforced. This is the failure the spec doc already forbids for keys, "no record-embedded key can bootstrap trust", applied to the policy instead. M is only meaningful when it comes from verifier-side configuration. `expected_policy_hash` and `expected_catalog_id` are now required arguments. A record's `policy_hash` must cover its own policy body, which `compute_policy_hash` defines so that producers and verifiers agree on it, and must equal the policy the verifier was configured with. Neither can be defaulted without falling back to the record's own claim, so the signature change is deliberate; nothing calls this module yet. `catalog_id`, `sequence`, and `previous_catalog_hash` were format checked and then unused, so a record for a different catalog verified against this one. `catalog_id` is now always bound, and `expected_sequence` and `expected_previous_catalog_hash` join `expected_previous_record_hash` as optional checkpoints, optional because they must come from an external pin or transparency receipt. Distinctness now rejects repeats as each approval is read rather than counting distinct values afterwards, which had admitted alice, bob, alice at a threshold of two. String and integer fields are validated before use, so a non-hashable `role` raises `CatalogApprovalError` instead of escaping as `TypeError`, boolean timestamps are rejected as the shipped schema already rejects them, and the signature is checked against the base64url alphabet and for a 64 byte decode, with the decode moved out of the `try` block that was reporting its errors as "signature is invalid". The distinctness rules did not close the property on their own, because nothing bound approvals to distinct keys. A threshold of three under a policy that does not require distinct principals was satisfied by one key signing the identical approval three times, and a threshold of two under a policy that does require distinct roles was satisfied by one key signing twice as two different roles, since a `TrustedReviewer` carrying no role lets the record assert whichever role it likes. A reviewer key now counts once per record whatever the policy says: a repeated signature is one approval presented N times, not N approvals. The check runs after the principal and role rules so those keep reporting the more specific cause. The four tests that shipped with #519 passed whether or not any of these checks existed. The suite is now 31 tests, each guard verified by deleting it and confirming a test fails. One is xfailed on purpose: the schema sets a minimum of zero on the approval timestamps and the verifier does not, which is left failing until the schema-wiring decision lands. - **The catalog-approval signing input was not the JCS it claims to be (#517).** `canonical_json` is documented as RFC 8785 compatible and serialized with `ensure_ascii=True`, which is the one thing JCS does not do: it emits an ASCII escape where the standard emits UTF-8. Any record carrying a non-ASCII `principal_id`, `issuer`, `role`, `catalog_id`, or `policy_id` was signed over different bytes than a conforming producer signs, so a record produced anywhere but here failed with an invalid-signature error that points nowhere near the encoding. `sort_keys` was the second divergence, ordering members by code point where JCS orders by UTF-16 code unit. The two disagree for any key outside the BMP, since a surrogate pair leads with `0xD800` and sorts below a BMP character above `0xE000`. Members are now ordered on their UTF-16BE bytes and the output is UTF-8. Values JCS cannot pin down are refused rather than serialized into a signing input that two implementations would read differently: floating point numbers, integers beyond `2**53 - 1`, non-string keys, and unpaired surrogates, each as `CatalogApprovalError` rather than as an escaping `UnicodeEncodeError` or `TypeError`. Approval records carry none of those, so refusing them only closes a door. ASCII-only records serialize to the same bytes as before, pinned by test, so nothing that verifies today stops verifying. #517 asks for JCS reuse precisely so a record can cross implementations, and nothing consumes these records at runtime yet, so the encoding is still free to correct. - **`catalog.drift_policy` was unreachable from configuration (#523).** The parser defined the setting, but strict top-level validation rejected every `catalog:` block before it could be read. The block and its only supported key are now explicitly allowlisted; `fail_closed` remains the default, and misspelled nested keys fail closed. - **cMCP could not verify a v0.2 Agent Manifest at all (agent-manifest#315, phase 4 of agent-manifest#243).** The pin moved to `agent-manifest>=0.11`, which carries the COSE verifier, and nothing here ever presented a v0.2 manifest to it. It could not have worked: `load_agent_manifest` read JSON and `_verify_with_sdk` passed a dict, and from v0.2 the COSE_Sign1 structure **is** the signature (ADR-0011), so a v0.2 document handed over as a dict has nothing to appraise. The SDK correctly reported a missing signature, and an operator would have read that as a malformed manifest rather than a manifest supplied in the wrong form. `load_agent_manifest_document()` now returns the decoded document alongside the envelope bytes it arrived in, and the envelope is what reaches `verify_manifest` when there is one. The file is sniffed rather than switched on its extension: a COSE envelope is CBOR and never parses as JSON, so trying JSON first is unambiguous and an operator does not have to name the file correctly for the gateway to read it. A v0.2 payload supplied as bare JSON is now named as such. `load_agent_manifest()` keeps its dict-returning signature for callers that only read identity fields. - **TDX read both of the fields it attests from the wrong offsets, and enforced neither (#371).** `REPORTDATA` was read at `0x08`, inside REPORTMACSTRUCT's leading RESERVED block, and a mismatch was advisory: the binding between the report and the confirmation key was neither checked in the right place nor enforced where it was checked. `MRTD` was read at `0x90`, which is inside `REPORTMACSTRUCT.report_data`, the 64 bytes the guest itself supplies. The producer wrote the nonce there and then hashed 48 bytes of it back out as the "TD measurement", so two attestations of the same TD with different nonces produced different measurements. A measurement that moves with the nonce is not a measurement. Both offsets now come from `cmcp_runtime.tee.tdreport`, one ctypes definition of TDREPORT_STRUCT imported by the producer and the verifier, with the ABI offsets asserted at import: `REPORTDATA` at `0x80`, `MRTD` at `0x210`. Producer and verifier having their own copies is how two wrong offsets agreed with each other for this long. A `report_data` mismatch is now fatal on TDX, matching the SEV-SNP rule from #390. - **A TDX claim could be reported as fully `verified` with the quote signature unverified (#370).** `LIMITATIONS.md` already said a claim whose report signature or chain is unverified stays `partially_verified` and is never presented as hardware-backed. That was true of SEV-SNP, which #390 gated on the VCEK chain, and not of TDX, where a TDREPORT alone was enough. A TDREPORT is an unsigned buffer the host could have written. TDX is now gated on `dcap_quote_signature` exactly as SNP is gated on `vcek_cert_chain`, so the documentation and the code agree. - **`initialize` answered every handshake with a revision that has no `initialize` (#509).** Third on the to-do list in #496 was "stop hardcoding the downstream protocol version", and #509 did it by replacing the hardcoded `2024-11-05` in the `initialize` result with `PROTOCOL_VERSION`. That constant is `2026-07-28`: the revision that **removed** the handshake. So the gateway answered every `initialize` by naming a protocol in which the request just made does not exist, and in which each later request must carry `_meta` plus the mirrored `MCP-Protocol-Version` / `Mcp-Method` headers a handshake-era client has no way to know it should send. Confirmed against every revision a real client offers: asked `2025-06-18`, `2025-03-26` or `2024-11-05`, the gateway answered `2026-07-28` in all three cases. The direction of the swap is the whole defect. `PROTOCOL_VERSION` is correct on the **outbound** leg, where the gateway is the client and #509 got it right; it is wrong on the **inbound** one, where reaching `initialize` at all is proof the caller is handshake-era. `initialize` now negotiates over `_LEGACY_PROTOCOL_VERSIONS` only, echoing the client's request when the gateway speaks it and otherwise answering with the newest handshake-era revision. A client that asks for `2026-07-28` at a handshake is deliberately not humoured: it cannot be speaking a revision with no handshake, so confirming it would agree on a protocol neither side is using. `server.py` no longer imports `PROTOCOL_VERSION`; #509 introduced that import solely for this misuse. That set now leads with **`2025-11-25`**, the newest revision that still defines `initialize`. It had been omitted, so a client offering the latest handshake revision was answered `2025-06-18` instead. The lifecycle spec requires a server to echo a version it supports and says a client that does not support the server's answer SHOULD disconnect, which makes a needless downgrade the same class of defect as the one above, one revision over. Non-object `initialize` params are now rejected with `-32600` rather than treated as an empty object. `InitializeRequestParams` is an object with required members, so answering a malformed handshake with a successful negotiation blessed a validation gap. This matches how #500 already rejects non-object `tools/call` params. Absent `params` remains legal and negotiates the newest revision. `tests/unit/test_initialize_protocol_version.py` pins the negotiation and asserts the outbound constant is untouched. Verified by mutation: reverting only the `initialize` line while keeping the new constant fails 9 of its 10 tests. ### Security - The MCP ingress now rejects non-object JSON-RPC messages, non-string methods, and non-object `tools/call` parameters with a bounded `MCP_INVALID_REQUEST` response. Structurally invalid attacker input no longer reaches attribute errors, HTTP 500 responses, or exception trace logging. - The unauthenticated health/readiness rate limiter now expires inactive source-address entries and caps tracked clients at 10,000. Source-address churn can no longer grow the in-memory limiter map for the lifetime of the gateway. - Upstream stdio children and provenance verdicts are now cached by complete execution and trust identity rather than the non-unique human-readable `display_name`. Distinct catalog servers sharing a label can no longer reuse another server's process or provenance result. - Built wheels now include the catalog-entry JSON Schema, and catalog loading fails closed if that schema is absent or unreadable. Previously source-tree tests validated catalog structure, but installed wheels omitted the schema and silently skipped that validation. - PyPI publication now installs and smoke-tests the exact wheel and source distribution before upload, including release-tag/version agreement, import provenance, runtime configuration, and the packaged CLI. - The runtime container now uses a patch-pinned Python slim base, builds a non-editable production wheelhouse in a separate stage, excludes development dependencies and build tooling from the final image, and runs as an unprivileged numeric UID/GID. ### Changed - **`STATUS.md` understated the attestation default in two ways.** It gave the probe order as `tpm -> sev-snp -> tdx`, missing `azure-cvm`, which is probed *first* and exists precisely so an Azure confidential VM is not mistaken for a plain TPM host. And it said nothing about what happens when no hardware is found, which is the question an evaluator is actually asking. The answer is better than the omission implied and is now stated: **auto never degrades to software.** With no hardware detected the gateway refuses to start unless `CMCP_DEV_MODE=1`, and `provider: software-only` requires that flag as well, so a software-mode gateway is always a deployment that asked for one rather than a silent downgrade. `tests/unit/test_tee.py` pins both, including that setting `CMCP_DEV_MODE` in the environment *after* config load does not bypass it. Documentation only. No behaviour changed; the behaviour was already correct and the defaults table was selling it short. ### Added - **Configurable sensitivity vocabulary (#479).** The six built in sensitivity labels (`public`, `pii`, `confidential`, `hipaa_phi`, `mnpi`, `trade_secret`) were the only ones a catalog entry could ever declare, so a regulated deployment whose own scheme names a tier above `trade_secret`, an Open/Confidential/Secret/Top Secret ladder for example, had no way to catalogue it truthfully. `sensitivity.vocabulary` in config now lets a deployment add new labels at any rank. It is additive only and enforced at two layers: config parsing rejects a vocabulary key that names a built in label outright, and `session/state.py`'s `effective_sensitivity_order()` merges the built in table in last regardless, so a built in name can never be shadowed even by a bug elsewhere in the chain. That guarantee is not cosmetic: response inspection's content pattern detectors emit the built in tags `pii` and `hipaa_phi` directly when they spot an SSN, an email, a diagnosis code and so on, and if either name had silently dropped out of the effective vocabulary those detections would have ranked at 0, the same fail open hole #478 closed for the catalog schema. `SessionManager` and `PolicyEvaluator` each derive the effective vocabulary once from the same `Config`, so a session's `max_sensitivity` string and the `sensitivity_level` integer Cedar evaluates can never disagree about what a custom label ranks as. The catalog schema's `sensitivity_level` enum moved from the static JSON schema into a Python level check at load time against this same effective set, so the vocabulary stays closed, just not hardcoded. Per call classification, letting a single call declare a class narrower than its tool's catalogued default, is the second half of #479 and is left for a follow up change. - **Per call sensitivity classification (#479).** One catalogued tool can serve many classes of data, a model call tool catalogued at `pii` might, on a specific call, actually carry `confidential` data (agentrust-io/demos#36 is the motivating example). The signed transcript could only ever show the tool's single catalogued value, and the session's own sensitivity tracking never rose past what the catalog alone said either, an enforcement gap for later calls in the session, not only a record keeping one. A call may now declare a class for itself via `_cmcp.data_class` on the request. It composes with the vocabulary work above: a deployment adds a label in config, a call declares it per call. The declared value needs no separate validation, `_max_sensitivity` already is the validator: it returns whichever of two labels ranks higher and ties favour the catalog value, so an unrecognised or lower declared value is harmless by construction, and a legitimately higher one raises both the session's `max_sensitivity` and, independently, that specific call's own row in the signed transcript, without inheriting whatever the session had already accumulated from earlier calls. `AuditEntry` gained one new field, `effective_data_class`, `None` on every call that never declares one, so nothing changes for a caller that does not use `_cmcp.data_class`. - **Server provenance checking (step 3).** The gateway consumes [`server-provenance-v1`](https://github.com/agentrust-io/trace-spec/blob/main/spec/server-provenance-v1.md) records via `agentrust-trace>=0.8`: it verifies the record against a **configured** publisher key, then compares the record's tool-catalog hash against the tools the server advertises to this gateway. It never falls back to the catalog's own approved definitions for that comparison. Comparing a record against our approval instead of against the server is the substitution that turns the whole check into theatre. Five outcomes reach the audit chain and none is silent. `verified`; `catalog-mismatch`, which means the document is fine and the *server* is not; `invalid`; `unchecked`, which means the record verified but the tool list was unavailable so the comparison that catches a substituted server never ran; and `absent`. `unchecked` exists because reporting a signature check alone as `verified` is a document checking itself. **Absence is recorded and non-fatal by default.** Almost no MCP server has a record, and a gateway that refuses to route without one is a gateway that gets turned off on first contact and never turned back on. `attestation.required_provenance_kind` sets a floor per deployment; a `catalog-mismatch` satisfies no floor, including the weakest. A record with no configured publisher key is `invalid`, not `verified`: the key embedded in a record cannot be used to check that record, because a forgery supplies its own. ### 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`. ### Fixed - **The AIA chain walk for a TPM attestation key had no margin above the one depth it was measured against (#514).** `TPMProvider._chain_from_leaf` stops walking the certificate chain at `_AIA_MAX_DEPTH`, and that constant was `4`, exactly the length of the one real Azure hierarchy measured so far: leaf, Azure Cloud Virtual TPM CA - 11, Azure Cloud Virtual TPM CA 2025, root. Split out of #453, whose own investigation found a second real hierarchy, `Global Virtual TPM CA - 03`, that reaches the same root in only 3 certificates. Two different depths already exist on real fleet hardware, so a cap sized to match one of them exactly leaves zero room for a future or differently configured hierarchy that needs one more hop: the walk would stop one certificate short of the root, and the resulting chain would ship without it. Not a loop logic bug: the walk correctly builds any chain up to the cap, including one where the root arrives on the last allowed certificate. The cap is now `6`, two hops above the deeper of the two known depths rather than matching either exactly, and the walk logs a warning when it stops without reaching a self signed root, so a future truncation is visible in the logs immediately rather than only surfacing later as a chain verification failure that looks identical to a chain that legitimately reaches an untrusted root. ## [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. ### Changed - **BREAKING for verifiers: claims now carry `gateway.attestation_evidence` (#469, #370).** Signed platform evidence (`raw_evidence`, `quote_signature`, `cert_chain`, `ek_cert_chain`) travels inside the claim so the verifier has something to check the TPM quote against. It could not live under `trace.runtime`, because agentrust-trace's `RuntimeInfo` is `extra="forbid"` and rejected the claim as `CLAIM_MALFORMED` before the platform branch ran, which is precisely what kept the chain verifiers unreachable. The break is one-directional, and only for verifiers: | | result | |---|---| | this verifier reading an older claim with no evidence | fine, the fields are optional and `trace.runtime` is still read as a fallback | | a verifier older than 0.4.0 reading a claim from this gateway | `CLAIM_MALFORMED` on `gateway.attestation_evidence` | `GatewayAddenda` and `RuntimeClaim` are both `extra="forbid"`, so any additive field anywhere in the claim is rejected by a verifier built before it, and `verify_trace_claim` never reads `cmcp_version`. There is no negotiation path, so "evidence travels with the claim" and "older verifiers keep working" cannot both hold. Evidence transport won, since without it the TPM quote is unauthenticated. Anyone verifying claims from a 0.4.0 gateway must upgrade `cmcp-runtime` to 0.4.0, which is what ships `cmcp_verify`. Claims with no evidence serialize byte-identically to 0.3.0, so software-only deployments are unaffected. Minor rather than patch under SemVer: the wire format gained a field that older readers reject. ### Security Five changes below the headline TPM fix, each one a case where cMCP reported more assurance than it held. Collected here rather than scattered through Fixed, because an operator deciding whether to upgrade should be able to read them in one place. - **Tokenless development mode may now only bind a loopback address.** `CMCP_DEV_MODE=1` skips the bearer-token requirement, and the default `listen_addr` is `0.0.0.0:8443`, so the documented quickstart stood up an unauthenticated gateway on every interface of the host. Configuration is now refused unless the bind is loopback or `CMCP_BEARER_TOKEN` is set. The default bind is unchanged; what changed is that the unsafe combination no longer starts. - **A claim is no longer issued when the per-session TEE attestation call fails (#426).** `close_session` fell back to the shared startup report and signed a claim anyway. That report carries no chain-root commitment, so a strict verifier rejects it, but the runtime handed it out as if nothing were wrong. On a hardware platform this now raises `TeeFault` instead of issuing an unbound claim. Software-only dev mode keeps the previous fallback, where the binding was already best-effort. - **Unsigned PCR reads were labelled hardware-attested (#441).** A PCR read carries no signature and nothing binds it to a TPM, but the provider still reported `provider=tpm` when the quote failed, and the subprocess path never produces a quote at all. Both cases now downgrade to software-only with the note `tpm-pcr-read-unsigned`. Related: a PCR hex-parsing defect that corrupted the measurement itself (#437). - **SNP `report_data` mismatch is now fatal (#371, #390),** since that field carries the confirmation-key binding and the freshness nonce, and the dispatcher no longer reports `hardware_attestation` as verified while the VCEK chain is unverified (#370, #372); such a claim stays `PARTIALLY_VERIFIED`. Same defect class as the TPM headline, on the AMD path, fixed first. - **TCG event logs are replayed against the reported PCR values (#443)**, so a log that does not reproduce the quoted digests is detected rather than trusted as narrative. - **`gateway.agent_identity.agent_key_thumbprint` scaffold (#425).** SAGE (via l33tdawg, agentrust-io/.github discussion #15) wanted an offline check that a downstream signature came from the agent a TRACE Claim describes. Investigating turned up that the issue's own premise did not hold: `AgentManifestBinding` carries no agent public key anywhere, and `subject_source` is a static config value today, not a live-authenticated credential (`svid` is a valid value but nothing produces it). So there was no key material anywhere in the runtime to hash. Landed as a nullable, additive field instead: `agent_key_thumbprint` (RFC 7638 JWK thumbprint, `sha256:`) on `AgentIdentityInfo`/`AgentIdentityOut`, always `None` today since no code path supplies agent key bytes. The real, non-speculative piece is on the verifier side: `cmcp_verify.verify_trace_claim` now fails closed (`AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT`) on any claim that carries the field while `subject_source` is not live-authenticated, so a future producer cannot launder a config-supplied identity into something that looks like a hardware-attested key binding. Populating the field for real needs either an `agent_manifest_sdk` schema change carrying the agent's public key, or a live mTLS/challenge-response credential wired into `AgentManifestBinding` - both explicitly out of scope here, tracked in the issue thread. ### Fixed - **`TPM2_NV_Certify` could never have worked as shipped in #459 (hardware, 2026-08-01).** Two defects, both found by running it against a real Azure Trusted Launch vTPM and neither catchable by the unit tests as written: 1. `ESAPI.nv_certify` takes `in_scheme` (a `TPMT_SIG_SCHEME`) and `size` as **required positional** arguments. The shipped call omitted both, so it raised `TypeError` before the TPM was ever reached. The fake in the unit tests accepted any signature, so the tests passed against code that could not run. 2. A freshly defined `TPM_NT_EXTEND` index is **uninitialised**, and `TPM2_NV_Certify` on it fails with `TPM_RC_NV_UNINITIALIZED`. On a first gateway start there was therefore no pre-value to certify. Fixed by seeding the index once at provision time, which keeps the verifier's `post == H(pre || digest)` check free of a first-boot special case. The fake now enforces both constraints, so each defect has a regression test that fails without its fix (verified by mutation). What the run then established: the platform AK at `0x81000003` **can** sign an NV certify, which was an open question for a restricted signing key; `parse_nv_certify`'s field offsets are correct against a real blob, which was the highest-risk item since they came from the TCG spec and had never met real bytes; the extend relation holds across two consecutive starts with run 2's `pre` equal to run 1's `post`; and `verify_gateway_measurement` passes all seven steps while rejecting a wrong digest and a replayed nonce on genuine evidence. The chain was anchored on the leaf because that host presented the no-AIA hierarchy, so key provenance remains unproven on Azure (#453). ### Added - **The gateway measurement is now signed evidence (#432, second half).** The NV index value previously travelled as an ordinary NV read that no signature covers, so it was a local integrity control: a compromised gateway could report any value. `certify_and_extend_gateway_measurement` now certifies the index, extends it, and certifies again, giving two TPM-signed values. `cmcp_verify.nv_certify.verify_gateway_measurement` appraises the pair, checking `post == H(pre || expected_gateway_digest)` with nothing collector-asserted and no verifier-side state. Two certifies rather than one because `TPM2_NV_Certify` signs only the index's *current* value and cannot attest to a previous one, while extends accumulate across reboots so there is no absolute value a verifier could expect. The two calls commit different qualifying data (`pre` / `post`) so each blob's role is signed rather than inferred from position. The shortcut that looks equivalent and is not: reading the index and committing the result into a quote's qualifying data means the collector asserts the value it read, and a compromised gateway is the adversary. Only the platform attestation key certifies. A transient key would produce a verifiable signature with no provenance, which is worse than an honest absence because it looks like evidence, so a platform without a certified key falls back to the unsigned extend and ships no evidence at all. `TPMProvider.platform_attestation_key` exposes that distinction explicitly instead of leaving callers to read a transient-key side channel. `agent_manifest.verify_tpm_quote` cannot appraise an NV certify: it rejects any attest type that is not `TPM_ST_ATTEST_QUOTE`, and its parser assumes a `TPML_PCR_SELECTION` union. The certificate chain is still delegated to `agent_manifest.verify_cert_chain`; only the `TPM_ST_ATTEST_NV` and `TPMT_SIGNATURE` wire formats are local, tracked upstream as agentrust-io/agent-manifest#255. ### Changed - **Startup generates the signing key before measuring the gateway.** The measurement's certify calls commit the attestation nonce, which is derived from that key, so the key now comes first. It has no dependencies of its own, so this is ordering only and not a behaviour change. The documented sequence in `run_startup` is updated: detect provider, signing key and nonce, measure and certify, produce the report. - `mypy src` is clean for the first time. The five pre-existing errors in `cmcp_verify/tpm.py` were a `type[HashAlgorithm]` inference that made `hash_cls()` read as instantiating the abstract base; typing the lookup as a factory fixes it, and `padding.PSS.DIGEST_LENGTH` replaces `hash_cls.digest_size` for the same salt length with a correct type. ### Fixed - **The Azure vTPM certificate hierarchy is fleet variance, not a migration (hardware, 2026-08-01).** `docs/testing/hardware-validation.md` recorded on 2026-07-31 that "Azure has changed its vTPM PKI", after finding the AK certificate at NV `0x01C101D0` issued by `Azure Cloud Virtual TPM CA - 11` with a walkable AIA chain, superseding an earlier no-AIA observation. A VM provisioned 2026-08-01 (`Standard_D2s_v7`, eastus2) presented the older form again: 994 bytes, issued by `Global Virtual TPM CA - 03`, **no AIA extension at all**, and `tpm2_getcap handles-nv-index` confirmed no intermediates stored in NV as a fallback. Both hierarchies are live concurrently, so the planning assumption must be that a host may present either and **AIA cannot be relied on**. The practical consequence, now stated in the doc and in `cmcp_verify/tpm_roots.py`: pinning the 2023 root does not make Azure verify everywhere, and chained verification is impossible on a host presenting the no-AIA hierarchy. This is worth re-reading against #431, which was closed on the basis that chains ship with the evidence. ### Added - **Hardware validation for the gateway measurement NV extend index (#432, #451).** The TPM calls in `cmcp_runtime.tee.measurement` were written against the documented tpm2-pytss API without ever executing against a TPM. All of them work on a real Azure Trusted Launch vTPM: `nv_define_space` with `TPMA_NV.parse(...) | (TPM2_NT.EXTEND << 4)` created a genuine extend index (`TPM_NT = 4` read back from the public area), `nv_extend` accumulated as `H(old || data)` across calls, an existing index was reused rather than redefined, and **a plain `nv_write` to the index was refused by the TPM**, which is the check the tamper-evidence argument actually rests on. Recorded in `docs/testing/hardware-validation.md`. - **The gateway is now measured into a TPM NV extend index at startup (#432).** PCRs 0 through 7 cover firmware, option ROMs, boot configuration, and the bootloader, and there was no `PCR_Extend` anywhere in the codebase, so replacing the policy bundle or the gateway itself produced an identical measurement and the TPM authenticated no commitment to the thing the TPM path exists to measure. `cmcp_runtime.tee.measurement` digests the installed distributions' recorded per-file hashes (pip's `RECORD`), the policy bundle bytes, and the resolved configuration with secrets excluded, then extends that digest into NV `0x01500432` before the gateway serves traffic. `RuntimeContext` carries the result. An NV index with `TPM_NT_EXTEND` rather than an application PCR, per the decision on #432: PCR 23 and PCR 16 are both resettable from locality 0, so an adversary with local code execution could reset and re-extend a chosen value, which is exactly the adversary this tier addresses. Extend writes are one-way (`new = H(old || data)`), so forgery needs a preimage, which is what carries the security argument rather than the write policy the original proposal called for. Scope is deliberately honest about two things. Measuring dependencies means an editable install, which has no `RECORD`, cannot be measured: that is fatal in production and a warning under `CMCP_DEV_MODE`, rather than a confident digest over an unknown subset of code. And because extends accumulate across reboots, the index is a hash chain over every start rather than a predictable absolute value, so the collector reports the value both before and after the extend and continuity is what a relying party checks. ### Changed - **RFC section 5 P2 (`docs/spec/tpm-security-model.md`) rewritten** to record the NV-extend decision instead of leading with the application PCR it rejected, along with the residual-risk analysis: `TPM2_NV_UndefineSpace` with owner auth can erase the measurement, which guest root holds on Azure, so the property is tamper-evident rather than tamper-proof. Section 4.3 now separates what landed from what has not: the index value travels as an ordinary NV read, which the quote signature does not cover, so it is a local integrity control and not yet remote-verifiable evidence. `TPM2_NV_Certify` is the primitive that closes it; committing an NV read into the quote's qualifying data is not sound, because the collector would be asserting the value it read. ### Changed - **BREAKING: TRACE Claims now carry the v0.2 profile** `tag:agentrust-io.com,2026:trace-v0.2`, and `agentrust-trace` is pinned to `>=0.5`. The v0.1 URI named `agentrust.io`, a domain this project never controlled, which RFC 4151 does not permit for a tag URI (agentrust-io/trace-spec#107). A verifier on the v0.2 suite rejects a v0.1 claim, so producers and verifiers move together. Nothing else about the claim format changed. ### Changed - Moved every `agentrust.io` identifier to `agentrust-io.com`: the JSON Schema `$id` values for `trace-claim`, `audit-entry`, and `catalog-entry`, the `@context` URL in test fixtures, and the maintainer email. `agentrust.io` is not ours and resolves to parked AWS addresses, so these identifiers pointed at a domain we do not control. They never resolved, so nothing that worked stops working. The `tag:agentrust-io.com,2026:trace-v0.2` EAT profile identifier is deliberately unchanged; it is a cross-repo identifier inside signed payloads and needs a transition decision, not a rename. ### Fixed - README no longer claims hardware attestation unconditionally. "Each session produces a signed, hardware-attested TRACE Claim" contradicted the TL;DR two paragraphs below it, which tells you to `pip install cmcp-runtime` and start in software mode with no hardware. It now reads "hardware-attested when the gateway runs in a TEE and signed-only in software mode", which is what the runtime actually does. ### Fixed - Raised the `agent-manifest` floor to `>=0.6.1` and made an unappraisable manifest fail closed with a diagnostic message. `verify_agent_manifest_binding` runs the SDK verifier over a peer-supplied manifest, and before 0.6.1 a manifest declaring `ML-DSA-65` or `hybrid-Ed25519-ML-DSA-65` crashed the SDK with an uncaught `RuntimeError` on any install without the optional `[pq]` extra, so this path answered a crash instead of a rejection. The SDK now returns `UNVERIFIABLE`, which cMCP already rejects; the `UNVERIFIABLE` message now distinguishes "could not be verified" (missing trusted key or unavailable algorithm) from "verification failed" (a bad signature) and surfaces the verifier's own reason, because those call for different operator responses. - **The TDX verifier rejected every genuine DCAP v4 quote.** Real quotes nest the Quoting Enclave material: the bytes after the attestation key are a certification-data header of type 6 (`QE_REPORT_CERTIFICATION_DATA`) wrapping the QE report, its PCK signature, the auth data and the type-5 PCK chain. `parse_td_quote` read the QE report directly after the attestation key, six bytes early, so a real GCP C3 quote failed with `attestation_key_not_bound_to_qe`. The synthetic test fixture emitted the same flat layout, so CI validated the defect. Failure was closed, so this was a false negative rather than an unsound accept, but the TDX path had never worked against real evidence. The parser now handles the nested layout, the fixture builds the real shape, and a new test rejects the flat layout outright. ### Added - **Real-hardware validation for SEV-SNP and TDX**, recorded in [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md). `cmcp_verify` now verifies a genuine Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, paravisor `REPORT_DATA` binding) and a genuine GCP C3 Intel TDX DCAP v4 quote (PCK chain to the pinned Intel SGX Root CA, QE binding, quote signature) end to end. Both are env-gated (`CMCP_AZURE_FIXTURE_DIR`, `CMCP_TDX_FIXTURE_DIR`) because the evidence embeds per-CPU identifiers and cannot be committed. `test_real_tdx_quote_agrees_with_agent_manifest` pins cMCP's verdict to the shared verifier so the two cannot drift apart silently. STATUS and ROADMAP now say which platforms have been validated against real evidence and which have not (TPM has not). ### Changed - Attestation crypto now delegates to agent-manifest's shared verification library (`agent-manifest>=0.5`) instead of cMCP's own copies: the SEV-SNP report signature (`verify_snp_signature`) and the VCEK/PCK certificate-chain verification (the generic `verify_cert_chain`) are shared across the org rather than duplicated. cMCP keeps its own DCAP quote parser, `*VerificationResult` shapes, TR-field semantics, and report_data/qualifying-data bindings; behavior is unchanged (all tests pass unchanged). cMCP's TPM verifier stays local (Phase-1 parse-only: no AK signature/chain to share). ### Added - Azure confidential-VM attestation (`cmcp_runtime.tee.azure_cvm.AzureCVMProvider` + `cmcp_verify.azure_cvm`), **hardware-validated on live Azure SEV-SNP silicon**. Azure runs SNP behind a Hyper-V paravisor with no `/dev/sev-guest`; the SNP report is read from the vTPM NV index `0x01400001` and the guest cannot control `REPORT_DATA` (the paravisor binds the vTPM AK there). cMCP's nonce (`jwk_thumbprint || audit-root`) is therefore committed into an AK-signed TPM quote's qualifying data, with the AK rooted in silicon via the SNP report (`REPORT_DATA == sha256(runtime_data)`) and the VCEK→ASK→ARK chain (reusing `cmcp_verify.sev_snp`). Auto-detected first (before TPM/SEV-SNP) since Azure exposes no `/dev/sev-guest`. Carries its own `runtime.platform` value `azure-cvm-sev-snp` (requires `agentrust-trace>=0.4`) so a consumer keying on `runtime.platform` knows the root of trust is vTPM-rooted, not a guest-controlled SNP `report_data`. - `tool_transcript.entries`: privacy-preserving per-call view in the TRACE Claim (one entry per tool call with `tool_name`, `data_class` from the catalog, and the policy `decision`), derived from the audit chain so no raw parameters or response bodies are exposed. `tool_transcript.hash` continues to bind the full transcript to the audit-chain tip. Adds `transcript_entries_hash()` for offline recomputation. (#126) ### Fixed - Bare-metal `SEVSNPProvider` now obtains the SNP report via the kernel configfs-TSM interface (`/sys/kernel/config/tsm/report`) instead of a `/dev/sev-guest` ioctl. The previous ioctl number and inline request ABI were incorrect and failed on real hardware with `ENOTTY` ("inappropriate ioctl for device"). **Hardware-validated on a non-paravisor SEV-SNP guest (GCP N2D, AMD Milan):** the guest-supplied nonce lands in the report's `REPORT_DATA` and the report verifies against the AMD VCEK. - SNP report-version check in `cmcp_verify.sev_snp` now accepts version `>= 2` (was `(2, 3)` only). Real Milan hardware emits report version 5, which the old allowlist wrongly rejected as `invalid_snp_report_version`; the field offsets read are layout-stable across v2..v5 and the VCEK signature is the real gate. ## [0.3.0] - 2026-06-30 ### Security - Software-only (non-hardware-backed) claims now return `partially_verified` instead of `verified` (fail-closed); a real verification failure is never downgraded. - An external-execution receipt whose `linked_call_id` does not match the entry is no longer reported signature-valid (short-circuits). ## [0.2.0] - 2026-06-12 ### Added - Bearer-token auth (`Authorization: Bearer`) wired into the live gateway server - Upstream MCP forwarding: AGT pre-call interception, JSON-RPC forward to the attested catalog server, response size guard, injection/credential/PII response scanning - Durable SQLite audit store (WAL mode, synchronous) with TEE-anchored hash chains and orphaned-session detection - `POST /sessions/{id}/close` issues the signed TRACE Trust Record and rotates the session - Cedar `@annotation` metadata returned as structured advice on deny decisions (HITL payloads) - `cmcp-verify`: one-command verification of claims and signed audit bundles, tamper-evident - Fail-closed hardware verifiers (TPM, SEV-SNP, TDX, OPAQUE): no attestation evidence means no verification - Dev-mode records carry `platform: software-only`, never `tpm2` (requires `agentrust-trace>=0.1.1`) - Silent mode contract: operational logs quiet, audit evidence always recorded ## [0.1.0] - 2026-06-09 ### Added - Initial TEE gateway with provider support for TPM, SEV-SNP, TDX, and OPAQUE - Cedar policy enforcement for request authorization at the gateway layer - TRACE Claim generation using the `GatewayClaim` envelope from `agentrust-trace` - `cmcp-verify` standalone verifier for validating TRACE Claims offline - Audit chain with Ed25519 signing for tamper-evident log integrity [Unreleased]: https://github.com/agentrust-io/cmcp/compare/v0.6.0...HEAD [0.6.0]: https://github.com/agentrust-io/cmcp/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/agentrust-io/cmcp/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/agentrust-io/cmcp/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/agentrust-io/cmcp/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/agentrust-io/cmcp/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/agentrust-io/cmcp/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/agentrust-io/cmcp/releases/tag/v0.1.0