All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
initializeanswered every handshake with a revision that has noinitialize(#509). Third on the to-do list in #496 was "stop hardcoding the downstream protocol version", and #509 did it by replacing the hardcoded2024-11-05in theinitializeresult withPROTOCOL_VERSION. That constant is2026-07-28: the revision that removed the handshake. So the gateway answered everyinitializeby naming a protocol in which the request just made does not exist, and in which each later request must carry_metaplus the mirroredMCP-Protocol-Version/Mcp-Methodheaders a handshake-era client has no way to know it should send. Confirmed against every revision a real client offers: asked2025-06-18,2025-03-26or2024-11-05, the gateway answered2026-07-28in all three cases.The direction of the swap is the whole defect.
PROTOCOL_VERSIONis correct on the outbound leg, where the gateway is the client and #509 got it right; it is wrong on the inbound one, where reachinginitializeat all is proof the caller is handshake-era.initializenow negotiates over_LEGACY_PROTOCOL_VERSIONSonly, echoing the client's request when the gateway speaks it and otherwise answering with the newest handshake-era revision. A client that asks for2026-07-28at 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.pyno longer importsPROTOCOL_VERSION; #509 introduced that import solely for this misuse.That set now leads with
2025-11-25, the newest revision that still definesinitialize. It had been omitted, so a client offering the latest handshake revision was answered2025-06-18instead. 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
initializeparams are now rejected with-32600rather than treated as an empty object.InitializeRequestParamsis 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-objecttools/callparams. Absentparamsremains legal and negotiates the newest revision.tests/unit/test_initialize_protocol_version.pypins the negotiation and asserts the outbound constant is untouched. Verified by mutation: reverting only theinitializeline while keeping the new constant fails 9 of its 10 tests.
- The MCP ingress now rejects non-object JSON-RPC messages, non-string methods, and non-object
tools/callparameters with a boundedMCP_INVALID_REQUESTresponse. 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.
-
STATUS.mdunderstated the attestation default in two ways. It gave the probe order astpm -> sev-snp -> tdx, missingazure-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, andprovider: software-onlyrequires 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.pypins both, including that settingCMCP_DEV_MODEin 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.
-
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 abovetrade_secret, an Open/Confidential/Secret/Top Secret ladder for example, had no way to catalogue it truthfully.sensitivity.vocabularyin 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, andsession/state.py'seffective_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 tagspiiandhipaa_phidirectly 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.SessionManagerandPolicyEvaluatoreach derive the effective vocabulary once from the sameConfig, so a session'smax_sensitivitystring and thesensitivity_levelinteger Cedar evaluates can never disagree about what a custom label ranks as. The catalog schema'ssensitivity_levelenum 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
piimight, on a specific call, actually carryconfidentialdata (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_classon 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_sensitivityalready 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'smax_sensitivityand, independently, that specific call's own row in the signed transcript, without inheriting whatever the session had already accumulated from earlier calls.AuditEntrygained one new field,effective_data_class,Noneon 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-v1records viaagentrust-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; andabsent.uncheckedexists because reporting a signature check alone asverifiedis 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_kindsets a floor per deployment; acatalog-mismatchsatisfies no floor, including the weakest.A record with no configured publisher key is
invalid, notverified: the key embedded in a record cannot be used to check that record, because a forgery supplies its own.
-
stdio transport: the gateway spawns the MCP server as its own child, inside the enclave (#484).
docs/spec/transport.mdruled 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-measuredorspawn-unmeasured, never folded intohardware_attestation.Defaults:
attestation.allow_unmeasured_spawnisfalse, 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 asspawn-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_targetpins 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
urlandtls_fingerprintwhile stdio requiresspawn.
-
The AIA chain walk for a TPM attestation key had no margin above the one depth it was measured against (#514).
TPMProvider._chain_from_leafstops walking the certificate chain at_AIA_MAX_DEPTH, and that constant was4, 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.
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.
-
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 undertrace.runtime, because agentrust-trace'sRuntimeInfoisextra="forbid"and rejected the claim asCLAIM_MALFORMEDbefore 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.runtimeis still read as a fallbacka verifier older than 0.4.0 reading a claim from this gateway CLAIM_MALFORMEDongateway.attestation_evidenceGatewayAddendaandRuntimeClaimare bothextra="forbid", so any additive field anywhere in the claim is rejected by a verifier built before it, andverify_trace_claimnever readscmcp_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 upgradecmcp-runtimeto 0.4.0, which is what shipscmcp_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.
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=1skips the bearer-token requirement, and the defaultlisten_addris0.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 orCMCP_BEARER_TOKENis 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_sessionfell 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 raisesTeeFaultinstead 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=tpmwhen the quote failed, and the subprocess path never produces a quote at all. Both cases now downgrade to software-only with the notetpm-pcr-read-unsigned. Related: a PCR hex-parsing defect that corrupted the measurement itself (#437). -
SNP
report_datamismatch is now fatal (#371, #390), since that field carries the confirmation-key binding and the freshness nonce, and the dispatcher no longer reportshardware_attestationas verified while the VCEK chain is unverified (#370, #372); such a claim staysPARTIALLY_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_thumbprintscaffold (#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:AgentManifestBindingcarries no agent public key anywhere, andsubject_sourceis a static config value today, not a live-authenticated credential (svidis 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:<hex>) onAgentIdentityInfo/AgentIdentityOut, alwaysNonetoday since no code path supplies agent key bytes. The real, non-speculative piece is on the verifier side:cmcp_verify.verify_trace_claimnow fails closed (AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT) on any claim that carries the field whilesubject_sourceis 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_sdkschema change carrying the agent's public key, or a live mTLS/challenge-response credential wired intoAgentManifestBinding- both explicitly out of scope here, tracked in the issue thread.
-
TPM2_NV_Certifycould 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:ESAPI.nv_certifytakesin_scheme(aTPMT_SIG_SCHEME) andsizeas required positional arguments. The shipped call omitted both, so it raisedTypeErrorbefore the TPM was ever reached. The fake in the unit tests accepted any signature, so the tests passed against code that could not run.- A freshly defined
TPM_NT_EXTENDindex is uninitialised, andTPM2_NV_Certifyon it fails withTPM_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'spost == 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
0x81000003can 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'spreequal to run 1'spost; andverify_gateway_measurementpasses 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).
-
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_measurementnow certifies the index, extends it, and certifies again, giving two TPM-signed values.cmcp_verify.nv_certify.verify_gateway_measurementappraises the pair, checkingpost == H(pre || expected_gateway_digest)with nothing collector-asserted and no verifier-side state.Two certifies rather than one because
TPM2_NV_Certifysigns 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_keyexposes that distinction explicitly instead of leaving callers to read a transient-key side channel.agent_manifest.verify_tpm_quotecannot appraise an NV certify: it rejects any attest type that is notTPM_ST_ATTEST_QUOTE, and its parser assumes aTPML_PCR_SELECTIONunion. The certificate chain is still delegated toagent_manifest.verify_cert_chain; only theTPM_ST_ATTEST_NVandTPMT_SIGNATUREwire formats are local, tracked upstream as agentrust-io/agent-manifest#255.
- 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_startupis updated: detect provider, signing key and nonce, measure and certify, produce the report. mypy srcis clean for the first time. The five pre-existing errors incmcp_verify/tpm.pywere atype[HashAlgorithm]inference that madehash_cls()read as instantiating the abstract base; typing the lookup as a factory fixes it, andpadding.PSS.DIGEST_LENGTHreplaceshash_cls.digest_sizefor the same salt length with a correct type.
- The Azure vTPM certificate hierarchy is fleet variance, not a migration (hardware, 2026-08-01).
docs/testing/hardware-validation.mdrecorded on 2026-07-31 that "Azure has changed its vTPM PKI", after finding the AK certificate at NV0x01C101D0issued byAzure Cloud Virtual TPM CA - 11with 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 byGlobal Virtual TPM CA - 03, no AIA extension at all, andtpm2_getcap handles-nv-indexconfirmed 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 incmcp_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.
-
Hardware validation for the gateway measurement NV extend index (#432, #451). The TPM calls in
cmcp_runtime.tee.measurementwere 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_spacewithTPMA_NV.parse(...) | (TPM2_NT.EXTEND << 4)created a genuine extend index (TPM_NT = 4read back from the public area),nv_extendaccumulated asH(old || data)across calls, an existing index was reused rather than redefined, and a plainnv_writeto the index was refused by the TPM, which is the check the tamper-evidence argument actually rests on. Recorded indocs/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_Extendanywhere in the codebase, so replacing the policy bundle or the gateway itself produced an identical measurement and the TPM enforced nothing about the thing the TPM path exists to protect.cmcp_runtime.tee.measurementdigests the installed distributions' recorded per-file hashes (pip'sRECORD), the policy bundle bytes, and the resolved configuration with secrets excluded, then extends that digest into NV0x01500432before the gateway serves traffic.RuntimeContextcarries the result.An NV index with
TPM_NT_EXTENDrather 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 underCMCP_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.
- 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_UndefineSpacewith 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_Certifyis 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.
- BREAKING: TRACE Claims now carry the v0.2 profile
tag:agentrust-io.com,2026:trace-v0.2, andagentrust-traceis pinned to>=0.5. The v0.1 URI namedagentrust.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.
- Moved every
agentrust.ioidentifier toagentrust-io.com: the JSON Schema$idvalues fortrace-claim,audit-entry, andcatalog-entry, the@contextURL in test fixtures, and the maintainer email.agentrust.iois 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. Thetag:agentrust-io.com,2026:trace-v0.2EAT profile identifier is deliberately unchanged; it is a cross-repo identifier inside signed payloads and needs a transition decision, not a rename.
- 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-runtimeand 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.
- Raised the
agent-manifestfloor to>=0.6.1and made an unappraisable manifest fail closed with a diagnostic message.verify_agent_manifest_bindingruns the SDK verifier over a peer-supplied manifest, and before 0.6.1 a manifest declaringML-DSA-65orhybrid-Ed25519-ML-DSA-65crashed the SDK with an uncaughtRuntimeErroron any install without the optional[pq]extra, so this path answered a crash instead of a rejection. The SDK now returnsUNVERIFIABLE, which cMCP already rejects; theUNVERIFIABLEmessage 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_quoteread the QE report directly after the attestation key, six bytes early, so a real GCP C3 quote failed withattestation_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.
- Real-hardware validation for SEV-SNP and TDX, recorded in
docs/testing/hardware-validation.md.cmcp_verifynow verifies a genuine Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, paravisorREPORT_DATAbinding) 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_manifestpins 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).
- 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 genericverify_cert_chain) are shared across the org rather than duplicated. cMCP keeps its own DCAP quote parser,*VerificationResultshapes, 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).
- 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 index0x01400001and the guest cannot controlREPORT_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 (reusingcmcp_verify.sev_snp). Auto-detected first (before TPM/SEV-SNP) since Azure exposes no/dev/sev-guest. Carries its ownruntime.platformvalueazure-cvm-sev-snp(requiresagentrust-trace>=0.4) so a consumer keying onruntime.platformknows the root of trust is vTPM-rooted, not a guest-controlled SNPreport_data. tool_transcript.entries: privacy-preserving per-call view in the TRACE Claim (one entry per tool call withtool_name,data_classfrom the catalog, and the policydecision), derived from the audit chain so no raw parameters or response bodies are exposed.tool_transcript.hashcontinues to bind the full transcript to the audit-chain tip. Addstranscript_entries_hash()for offline recomputation. (#126)
- Bare-metal
SEVSNPProvidernow obtains the SNP report via the kernel configfs-TSM interface (/sys/kernel/config/tsm/report) instead of a/dev/sev-guestioctl. The previous ioctl number and inline request ABI were incorrect and failed on real hardware withENOTTY("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'sREPORT_DATAand the report verifies against the AMD VCEK. - SNP report-version check in
cmcp_verify.sev_snpnow accepts version>= 2(was(2, 3)only). Real Milan hardware emits report version 5, which the old allowlist wrongly rejected asinvalid_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
- Software-only (non-hardware-backed) claims now return
partially_verifiedinstead ofverified(fail-closed); a real verification failure is never downgraded. - An external-execution receipt whose
linked_call_iddoes not match the entry is no longer reported signature-valid (short-circuits).
0.2.0 - 2026-06-12
- 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}/closeissues the signed TRACE Trust Record and rotates the session- Cedar
@annotationmetadata 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, nevertpm2(requiresagentrust-trace>=0.1.1) - Silent mode contract: operational logs quiet, audit evidence always recorded
0.1.0 - 2026-06-09
- 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
GatewayClaimenvelope fromagentrust-trace cmcp-verifystandalone verifier for validating TRACE Claims offline- Audit chain with Ed25519 signing for tamper-evident log integrity