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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion docs/spec/session-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hex>`, 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
Expand All @@ -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. |
15 changes: 15 additions & 0 deletions schemas/trace-claim.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/test_trace_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,95 @@ 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)


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)