From c8b21d88d8c26475998c37cd9bee09ca0ff85114 Mon Sep 17 00:00:00 2001 From: Dipika Ranabhat Date: Fri, 11 Sep 2026 16:58:32 -0500 Subject: [PATCH 1/2] fix(trace): add missing agent_identity fields to the trace claim schema Closes #622. The gateway.agent_identity object in trace-claim.schema.json declared 8 properties while AgentIdentityOut carries 11. The three missing ones, intent_hash, agent_key_thumbprint, and enforcement_mode, reach the signed claim whenever an Agent Manifest binding is configured, which is the default once agent_manifest is set. Any such deployment was signing a claim that failed its own normative schema, a violation of TRACE-001. Adds the three properties to the schema, documents the schema evolution policy in SPEC.md so future additions do not repeat this, updates the agent_identity field table in session-policy.md, and adds a regression test that builds a claim with all three fields set and validates it against the schema. Verified the new test fails on the unmodified schema and passes once the properties are added. --- docs/SPEC.md | 2 ++ docs/spec/session-policy.md | 4 ++- schemas/trace-claim.schema.json | 15 ++++++++++++ tests/unit/test_trace_claim.py | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 930feb5c..aff11c87 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -190,6 +190,8 @@ Verification (no operator trust required): 4. Check `gateway.catalog.hash` against the approved catalog hash on record 5. Recompute `trace.tool_transcript.hash` and walk `gateway.audit_chain` root to tip for call-level detail +**Schema evolution.** `schemas/trace-claim.schema.json` is versioned by `cmcp_version`, and objects under it, `gateway.agent_identity` included, gain new optional properties across minor updates as the evidence the runtime signs grows (issue #622 was one such addition). `additionalProperties: false` in this file is a producer-side authoring aid, catching typos and unintended fields in what cMCP itself emits, not a contract that external verifiers should replicate against a pinned, possibly-older copy of the schema. A verifier MUST NOT reject an otherwise-valid claim solely because it carries object members the verifier's copy of the schema does not yet declare; it SHOULD instead validate the members it recognizes and ignore the rest. This is the same problem tracked more generally at `agentrust-io/trace-spec#116`, evidence outliving the verifiers that check it, and #622 is its first concrete instance in cMCP. + --- ## 6. Attestation Bindings (Phase 1 unique properties) diff --git a/docs/spec/session-policy.md b/docs/spec/session-policy.md index 1d8606ae..4f6c4175 100644 --- a/docs/spec/session-policy.md +++ b/docs/spec/session-policy.md @@ -194,6 +194,8 @@ This binding answers "who acted" for the session. It does not replace `trace.sub Offline verifiers SHOULD cross-check `gateway.agent_identity` against the signed manifest and trusted issuer key. This keeps the runtime boundary check and the evidence artifact self-checking. +`gateway.agent_identity` MAY also carry `intent_hash` (AARM R2: a digest of the issuer-signed declared intent, agent-manifest spec ยง3.9) and `enforcement_mode` (the Agent Manifest binding's enforcement mode at session creation, distinct from `trace.policy.enforcement_mode`). Both are populated whenever the bound manifest supplies them, which is the common case once `agent_manifest` is configured. + `gateway.agent_identity` MAY also carry `agent_key_thumbprint`: an RFC 7638 JWK thumbprint of the agent's own signing key, rendered as `sha256:`, distinct from `issuer_key_id` (the key that signed the manifest). It is optional and is omitted from every claim the current runtime can produce, since no code path here has @@ -210,4 +212,4 @@ The following fields from session state are included in the TRACE attestation re |-------|------|-------------| | `session_max_sensitivity` | string | The highest `max_sensitivity` value reached during the session. | | `session_reset_count` | integer | Number of times `POST /session/reset` was called during the session lifetime. Normally `0`; a non-zero value warrants review. | -| `agent_identity` | object | Optional Agent Manifest binding: manifest ID, bound agent ID, authenticated subject, subject source, issuer key ID, policy hash, catalog hash, and an optional `agent_key_thumbprint`. Present only when `agent_manifest` is configured and verified. | +| `agent_identity` | object | Optional Agent Manifest binding: manifest ID, bound agent ID, authenticated subject, subject source, issuer key ID, policy hash, catalog hash, and the optional `intent_hash`, `agent_key_thumbprint`, and `enforcement_mode`. Present only when `agent_manifest` is configured and verified. | diff --git a/schemas/trace-claim.schema.json b/schemas/trace-claim.schema.json index 5cbd1b9c..f30fd119 100644 --- a/schemas/trace-claim.schema.json +++ b/schemas/trace-claim.schema.json @@ -235,6 +235,21 @@ "tool_catalog_hash": { "type": "string", "pattern": "^sha(256|384):[0-9a-f]+" + }, + "intent_hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "AARM R2: digest of the issuer-signed declared intent (agent-manifest spec 3.9). Absent when the bound manifest declares no intent." + }, + "agent_key_thumbprint": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "RFC 7638 JWK thumbprint of the agent's own signing key (#425), distinct from issuer_key_id. Only ever set when subject_source names a live-authenticated source." + }, + "enforcement_mode": { + "type": "string", + "enum": ["enforcing", "advisory", "silent"], + "description": "The Agent Manifest binding's enforcement mode at session creation, distinct from trace.policy.enforcement_mode." } } }, diff --git a/tests/unit/test_trace_claim.py b/tests/unit/test_trace_claim.py index 5cc9d052..a418acf3 100644 --- a/tests/unit/test_trace_claim.py +++ b/tests/unit/test_trace_claim.py @@ -559,3 +559,46 @@ def test_software_only_claim_validates_against_json_schema(): schema = json.loads(schema_path.read_text()) claim = _make_claim() jsonschema.validate(instance=_to_dict(claim), schema=schema) + + +def test_agent_identity_binding_with_intent_hash_and_enforcement_mode_validates_against_json_schema(): + """#622: gateway.agent_identity's schema rejected intent_hash, agent_key_thumbprint, + and enforcement_mode, three of AgentIdentityOut's fields, even though the runtime + sets intent_hash and enforcement_mode from the Agent Manifest binding by default + (config.py's enforcement_mode default is "enforcing", startup.py always supplies + it). Any deployment with a manifest binding was signing a claim the project's own + normative schema rejected. This reproduces that shape end to end and pins the fix. + """ + schema_path = pathlib.Path(__file__).parents[2] / "schemas" / "trace-claim.schema.json" + schema = json.loads(schema_path.read_text()) + key = SigningKey() + chain = AuditChain("sess-622") + claim = generate_trace_claim( + session_id="sess-622", + signing_key=key, + attestation_report=_make_report(), + policy_bundle=PolicyBundleInfo( + hash="sha256:" + "0" * 64, + enforcement_mode="enforcing", + policy_version="1.0.0", + ), + tool_catalog=ToolCatalogInfo(hash="sha256:" + "1" * 64), + call_summary=_make_call_summary(), + audit_chain_root=chain.chain_root, + audit_chain_tip=chain.chain_tip, + audit_chain_length=chain.length, + agent_identity=AgentIdentityInfo( + manifest_id="0197739a-8c00-7000-8000-000000000001", + agent_id="spiffe://factory.example/agent/material-movement/dev", + authenticated_subject="spiffe://factory.example/agent/material-movement/dev", + subject_source="config", + issuer="spiffe://factory.example/signing-authority/development", + issuer_key_id="a" * 64, + policy_bundle_hash="sha256:" + "0" * 64, + tool_catalog_hash="sha256:" + "1" * 64, + intent_hash="sha256:" + "b" * 64, + enforcement_mode="enforcing", + ), + do_sign=False, + ) + jsonschema.validate(instance=_to_dict(claim), schema=schema) From 77e94d0f4ca230707bc9fc906b3d820f258f432a Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 12 Sep 2026 13:30:20 -0700 Subject: [PATCH 2/2] test(trace-claim): cover agent_key_thumbprint on a live-authenticated subject The regression test in this PR carries intent_hash and enforcement_mode but not agent_key_thumbprint, so a third of the schema change had no coverage. Caught by @rajnisht7 in review. Adding it to the existing case would have pinned a combination the runtime is documented never to emit: session/manager.py omits the thumbprint deliberately because AgentManifestBinding carries no agent key bytes, and the schema says it is only ever set when subject_source names a live-authenticated source, while that test uses subject_source="config". So this is a sibling test with subject_source="svid", which is the shape the schema must already accept on the day #425 wires a real key source. Verified in both directions: it fails against main's schema and passes against this branch's. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X27MBo6tGVw1UmmchuGFnW --- tests/unit/test_trace_claim.py | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit/test_trace_claim.py b/tests/unit/test_trace_claim.py index a418acf3..48bceb3f 100644 --- a/tests/unit/test_trace_claim.py +++ b/tests/unit/test_trace_claim.py @@ -602,3 +602,52 @@ def test_agent_identity_binding_with_intent_hash_and_enforcement_mode_validates_ do_sign=False, ) jsonschema.validate(instance=_to_dict(claim), schema=schema) + + +def test_claim_schema_accepts_an_agent_key_thumbprint_on_a_live_authenticated_subject(): + """#622's third field, which the test above cannot carry. + + `agent_key_thumbprint` is the one of the three the runtime does not set yet: + `session/manager.py` omits it deliberately because `AgentManifestBinding` carries no + agent key bytes, and its schema description says it is "only ever set when + subject_source names a live-authenticated source". Adding it to the `config` case + above would pin a combination the runtime is documented never to emit as valid, so + the coverage belongs here, with `subject_source="svid"`: the shape the schema must + already accept on the day #425 wires a real key source. + """ + schema_path = pathlib.Path(__file__).parents[2] / "schemas" / "trace-claim.schema.json" + schema = json.loads(schema_path.read_text()) + key = SigningKey() + chain = AuditChain("sess-622-thumbprint") + claim = generate_trace_claim( + session_id="sess-622-thumbprint", + signing_key=key, + attestation_report=_make_report(), + policy_bundle=PolicyBundleInfo( + hash="sha256:" + "0" * 64, + enforcement_mode="enforcing", + policy_version="1.0.0", + ), + tool_catalog=ToolCatalogInfo(hash="sha256:" + "1" * 64), + call_summary=_make_call_summary(), + audit_chain_root=chain.chain_root, + audit_chain_tip=chain.chain_tip, + audit_chain_length=chain.length, + agent_identity=AgentIdentityInfo( + manifest_id="0197739a-8c00-7000-8000-000000000002", + agent_id="spiffe://factory.example/agent/material-movement/prod", + authenticated_subject="spiffe://factory.example/agent/material-movement/prod", + subject_source="svid", + issuer="spiffe://factory.example/signing-authority/production", + issuer_key_id="a" * 64, + policy_bundle_hash="sha256:" + "0" * 64, + tool_catalog_hash="sha256:" + "1" * 64, + intent_hash="sha256:" + "b" * 64, + agent_key_thumbprint="sha256:" + "c" * 64, + enforcement_mode="enforcing", + ), + do_sign=False, + ) + emitted = _to_dict(claim) + assert emitted["gateway"]["agent_identity"]["agent_key_thumbprint"] == "sha256:" + "c" * 64 + jsonschema.validate(instance=emitted, schema=schema)