diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c7fda9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ + +# Tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f87c5..7965072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. --- +## [Unreleased] + +### Specification + +- Optional agent-identity binding (§3.1.1): a new OPTIONAL `agent` block carries the signed Agent Manifest identity bound to the runtime session, distinct from `subject`. When present it MUST carry `agent_id` and `manifest_id`; `binding` is optional and informational only (verifiers MUST NOT base trust on it; initial values `svid-matched`, `manifest-presented`, `operator-asserted`). `manifest_id` is format-agnostic (byte-equal comparison). Adds an optional offline agent-identity cross-check to the verification protocol (§3.3); the catalog half is deferred as a future extension (§7). `subject == agent.agent_id` is permitted. Backward compatible. (#33) + +### Schema + +- `schema/trace-claim.json`: optional `agent` object requiring `agent_id` + `manifest_id` when present. + +### Examples + +- `examples/agent-bound-tdx.json`: Trust Record carrying the `agent` block. + +--- + ## [0.1.0] — 2026-06-23 Initial public draft. Announced at Confidential Computing Summit, San Francisco. diff --git a/examples/agent-bound-tdx.json b/examples/agent-bound-tdx.json new file mode 100644 index 0000000..bd87fa3 --- /dev/null +++ b/examples/agent-bound-tdx.json @@ -0,0 +1,57 @@ +{ + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1750676400, + "subject": "spiffe://cmcp.gateway/session/0197739a-8c00-7000-8000-000000000001", + "agent": { + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + "binding": "svid-matched" + }, + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "version": "20251001", + "weights_digest": "sha256:9a129038d9a00aed0cf6a7ea059ca50a813449061ab87848cf1a13eafdf33b2c" + }, + "runtime": { + "platform": "intel-tdx", + "measurement": "sha384:302165ab29dac9ee86adf8cf208ca21030ef10add5e41044be60f62e11331ef6387d9e5fe606974ac921cb9c9cbb4791", + "rim_uri": "https://api.trustedservices.intel.com/sgx/certification/v4/rootcacrl", + "firmware_version": "2.0.3", + "nonce": "dGRYLXdvcmtsb2FkLW5vbmNl" + }, + "policy": { + "bundle_hash": "sha256:823412d1eacb67956220e532959f0104603057c88704863ca38e7cd188fda812", + "enforcement_mode": "enforce", + "version": "1.2.0", + "policy_uri": "https://registry.agentrust.io/policy/material-movement-v1.2.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:54e6289e14c7b0e7ad9acc2dfc4c1e3d027d0eef7f5c4c3fe7c292761d0e06a6", + "call_count": 3, + "transcript_uri": "https://registry.agentrust.io/transcript/agent-bound-2026-06-23T09:20:00Z" + }, + "build_provenance": { + "slsa_level": 3, + "builder": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml", + "digest": "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d", + "provenance_uri": "https://rekor.sigstore.dev/api/v1/log/entries/def456" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://api.trustedservices.intel.com", + "policy_ref": "https://api.trustedservices.intel.com/policy/tdx-agent-v1", + "timestamp": 1750676403 + }, + "transparency": "https://registry.agentrust.io/claim/trace-2026-06-23T09:20:00Z-b0c1d2", + "cnf": { + "jwk": { + "kty": "EC", + "crv": "P-384", + "x": "TDXd2VyaWZpY2F0aW9uS2V5", + "y": "UmVmZXJlbmNlTWVhc3VyZW1l", + "kid": "tdx-workload-key-2026-06-23" + } + } +} diff --git a/schema/trace-claim.json b/schema/trace-claim.json index db8a2fc..a1a1edf 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -33,6 +33,29 @@ "description": "Workload identity as a SPIFFE SVID URI or DID URI.", "pattern": "^(spiffe://|did:)" }, + "agent": { + "type": "object", + "description": "OPTIONAL bound agent identity from a signed Agent Manifest, distinct from subject (the gateway session). Lets a verifier confirm offline that the approved agent is the agent that acted. When present it MUST carry agent_id and manifest_id. See spec section 3.1.1.", + "required": ["agent_id", "manifest_id"], + "properties": { + "agent_id": { + "type": "string", + "description": "Agent identity asserted by the signed Agent Manifest (SPIFFE SVID or DID URI).", + "pattern": "^(spiffe://|did:)" + }, + "manifest_id": { + "type": "string", + "description": "Identifier of the Agent Manifest bound to this session. Format-agnostic (UUID, URI, or digest); compared by byte-equal string match.", + "minLength": 1 + }, + "binding": { + "type": "string", + "description": "OPTIONAL, informational only: how the gateway established the manifest-to-session binding. Verifiers MUST NOT make trust decisions on its value. Initial registered values: 'svid-matched', 'manifest-presented', 'operator-asserted'.", + "examples": ["svid-matched", "manifest-presented", "operator-asserted"] + } + }, + "additionalProperties": false + }, "model": { "type": "object", "description": "Model identity and provenance.", diff --git a/spec/trace-v0.1.md b/spec/trace-v0.1.md index c7406b3..381fc5f 100644 --- a/spec/trace-v0.1.md +++ b/spec/trace-v0.1.md @@ -104,6 +104,7 @@ The Trust Record is the unit of evidence. All fields are required unless marked | Field | Description | Source primitive | |---|---|---| | `subject` | Workload identity (agent, tool, model invocation) | SPIFFE SVID or DID URI | +| `agent` | OPTIONAL. Bound agent identity (`agent_id`, `manifest_id`, `binding`) when the executing agent is governed by a signed Agent Manifest. Distinct from `subject`. See §3.1.1. | Agent Manifest `agent_id` bound to the runtime session | | `model` | Model identity, weights digest, version | EAT claim + AIBOM reference | | `runtime` | TEE measurement chain (firmware → kernel → image → workload) | RATS Evidence + vendor RIM | | `policy` | Bound policy set hash + enforcement mode. `enforcement_mode` MUST default to `enforce`; a deployment MUST explicitly configure `silent` mode. | Policy artifact hash sealed to TEE measurement | @@ -119,6 +120,32 @@ The Trust Record is the unit of evidence. All fields are required unless marked Each field is independently verifiable. Sub-records (e.g., per-tool-call transcripts) compose under one root envelope. +#### 3.1.1 Agent identity binding + + + +`subject` identifies the workload that produced the record — for the cMCP reference implementation this is the gateway session SVID (e.g. `spiffe://cmcp.gateway/session/`). A session SVID proves that an approved policy and catalog ran, but it does not by itself prove *which* agent ran: the agent the operator reviewed and approved (the signed Agent Manifest `agent_id`) may differ from the identity that actually acted. + +When the executing agent is governed by a signed Agent Manifest and the gateway binds that manifest to the runtime session, the Trust Record MAY carry an OPTIONAL `agent` block recording the bound identity so the binding is verifiable offline: + +```json +"agent": { + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + "binding": "svid-matched" +} +``` + +- `agent_id` — the agent identity asserted by the signed Agent Manifest (SPIFFE SVID or DID URI). +- `manifest_id` — the identifier of the Agent Manifest bound to this session. This field is format-agnostic: it MAY be a UUID (the example is a UUIDv7), a URI, or a digest. Verifiers compare it by byte-equal string matching against the manifest's own identifier; they MUST NOT assume a particular structure. +- `binding` — OPTIONAL. How the gateway established the manifest-to-session binding. This field is **informational and diagnostic only**: verifiers MUST NOT make a trust decision based on its value. The initial registered values are `svid-matched` (the manifest's bound SVID equals the session SVID's agent segment), `manifest-presented` (the session presented a signed manifest that the gateway verified), and `operator-asserted` (an operator asserted the binding out of band). Implementations MAY use other values; an unrecognized value MUST be treated as `binding` being absent. New values are registered by spec change (see §7). + +The `agent` block is OPTIONAL as a whole. A record without it remains valid; `subject` is unchanged and continues to identify the session. **When the `agent` block is present it MUST carry both `agent_id` and `manifest_id`** — these two fields are the binding evidence, and a block with neither is unverifiable noise. `binding` remains optional. The `agent` block is additive evidence that lets a third party verify the agent identity offline (§3.3); it never replaces `subject`. + +`subject` and `agent.agent_id` are conceptually distinct: `subject` is the session, `agent.agent_id` is the governed agent. In the cMCP reference design they always differ. The spec does **not** require them to differ, however — a deployment where the workload itself is the governed agent MAY set them equal. Equality carries no special meaning and a verifier MUST NOT reject a record solely because `subject == agent.agent_id`. + +**Canonical block vs. profile addenda.** The canonical `agent` block is intentionally the minimal *portable* identity: `agent_id`, `manifest_id`, and the informational `binding`. A profile MAY carry richer, implementation-specific binding evidence in its own addenda rather than in the canonical block — this keeps PKI and catalog concerns, which are profile- and deployment-specific, out of the vendor-neutral record. The cMCP reference profile does exactly this: alongside the canonical `agent` block it emits a `gateway.agent_identity` addendum carrying `authenticated_subject`, `issuer`, `issuer_key_id`, `policy_bundle_hash`, and `tool_catalog_hash` (cMCP #302). A TRACE verifier relies only on the canonical `agent` block for the cross-check below (§3.3); a cMCP-aware verifier MAY additionally consume the richer addendum. The canonical fields are the subset every TRACE verifier can rely on; profile addenda are additive and never required for a TRACE-level cross-check. + ### 3.2 Wire format **Envelope:** EAT (RFC 9711) — JWT (JSON, human-readable contexts) or CWT/CBOR-COSE (constrained and high-throughput contexts). @@ -211,6 +238,20 @@ Any party — browser, CLI, in-cluster verifier, third-party auditor — verifie No callback to the issuer. No vendor in the trust path beyond silicon root and transparency log operators. + + +**Agent-identity cross-check (OPTIONAL).** When a record carries an `agent` block (§3.1.1) and the verifier holds the corresponding signed Agent Manifest out of band, the verifier MAY perform a self-checking cross-check: + +1. `agent.agent_id` equals the manifest `agent_id` (byte-equal string match). +2. `agent.manifest_id` equals the manifest `manifest_id` (byte-equal string match). +3. The policy hash the manifest binds equals `policy.bundle_hash` in this record. + +A verifier that cannot complete every applicable step MUST treat the cross-check as not performed (the `agent` block is unverified), not as a record failure (see below). + +> **Note (future extension):** a manifest may also bind a tool-catalog hash. The v0.1 Trust Record has no catalog-hash field, so the catalog half of this cross-check is **not implementable against v0.1 records** and is intentionally omitted here. Adding a catalog-hash claim and the matching cross-check step is tracked as a future extension (see §7 and cMCP #302). + +This is a defense-in-depth layer: it confirms the agent the operator approved is the agent that acted, without a callback to the gateway. A present-but-uncheckable `agent` block (no manifest supplied) is unverified, not invalid: it does not affect the validity of the gateway-produced record. A record with no `agent` block is verified exactly as before. + ### 3.4 Scope TRACE governs any confidential workload — AI agent execution, regulated data processing, sovereign compute, secure multi-party computation. AI agents are the forcing function and the first reference profile, not the limit of the standard. @@ -332,6 +373,8 @@ These need input before v0.2: 5. **Privacy of the record.** Records may contain sensitive classifications. Standardize encrypted-claims envelope (JWE / COSE-Encrypt) from v1.0? 6. **A2A profile timing.** Ship A2A as a peer profile to MCP in v1.0, or wait for A2A to stabilize? 7. **Relationship to IETF AIIP.** Absorb, supersede, or coexist with draft-ritz-aiip? +8. **`agent.binding` value registry.** §3.1.1 seeds three informational values (`svid-matched`, `manifest-presented`, `operator-asserted`). Where is the registry maintained, and should any value ever become trust-affecting rather than informational? +9. **Catalog-hash claim.** The §3.3 agent-identity cross-check omits the tool-catalog half because v0.1 carries no catalog-hash field. Should v0.2 add a catalog-hash claim to the Trust Record (paired with the cMCP manifest binding, cMCP #302)? --- diff --git a/src/agentrust_trace/__init__.py b/src/agentrust_trace/__init__.py index 11cb6ee..a1a0603 100644 --- a/src/agentrust_trace/__init__.py +++ b/src/agentrust_trace/__init__.py @@ -1,6 +1,7 @@ """agentrust-trace — TRACE Trust Record models, validation, and signing.""" from agentrust_trace.models import ( + AgentIdentity, Appraisal, BuildProvenance, ConfirmationKey, @@ -28,6 +29,7 @@ __all__ = [ "__version__", + "AgentIdentity", "Appraisal", "BuildProvenance", "ConfirmationKey", diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index 847a5fb..aeba117 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -9,6 +9,23 @@ DigestStr = Annotated[str, Field(pattern=_DIGEST_RE)] +class AgentIdentity(BaseModel): + """Bound agent identity from a signed Agent Manifest (spec §3.1.1). + + Distinct from TrustRecord.subject (the gateway session). The whole block is + optional on the record, but when it is present it MUST carry both agent_id and + manifest_id: those two fields are the binding evidence, and a block with neither + is unverifiable. binding is informational only (verifiers MUST NOT base trust on + its value). + """ + + model_config = ConfigDict(extra="forbid") + + agent_id: Annotated[str, Field(pattern=r"^(spiffe://|did:)")] + manifest_id: Annotated[str, Field(min_length=1)] + binding: str | None = None + + class ModelInfo(BaseModel): model_config = ConfigDict(extra="forbid") @@ -114,6 +131,7 @@ class TrustRecord(BaseModel): eat_profile: Literal["tag:agentrust.io,2026:trace-v0.1"] iat: Annotated[int, Field(ge=1700000000)] subject: Annotated[str, Field(pattern=r"^(spiffe://|did:)")] + agent: AgentIdentity | None = None model: ModelInfo runtime: RuntimeInfo policy: PolicyInfo diff --git a/src/agentrust_trace/schema/trace-v0.1.json b/src/agentrust_trace/schema/trace-v0.1.json index 0c03bb2..194925b 100644 --- a/src/agentrust_trace/schema/trace-v0.1.json +++ b/src/agentrust_trace/schema/trace-v0.1.json @@ -33,6 +33,29 @@ "description": "Workload identity as a SPIFFE SVID URI.", "pattern": "^spiffe://" }, + "agent": { + "type": "object", + "description": "OPTIONAL bound agent identity from a signed Agent Manifest, distinct from subject (the gateway session). Lets a verifier confirm offline that the approved agent is the agent that acted. When present it MUST carry agent_id and manifest_id. See spec section 3.1.1.", + "required": ["agent_id", "manifest_id"], + "properties": { + "agent_id": { + "type": "string", + "description": "Agent identity asserted by the signed Agent Manifest (SPIFFE SVID or DID URI).", + "pattern": "^(spiffe://|did:)" + }, + "manifest_id": { + "type": "string", + "description": "Identifier of the Agent Manifest bound to this session. Format-agnostic (UUID, URI, or digest); compared by byte-equal string match.", + "minLength": 1 + }, + "binding": { + "type": "string", + "description": "OPTIONAL, informational only: how the gateway established the manifest-to-session binding. Verifiers MUST NOT make trust decisions on its value. Initial registered values: 'svid-matched', 'manifest-presented', 'operator-asserted'.", + "examples": ["svid-matched", "manifest-presented", "operator-asserted"] + } + }, + "additionalProperties": false + }, "model": { "type": "object", "description": "Model identity and provenance.", diff --git a/tests/test_models.py b/tests/test_models.py index f1799f0..465c7d8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -16,7 +16,10 @@ def _load(name: str) -> dict: return json.loads((EXAMPLES_DIR / name).read_text()) -@pytest.mark.parametrize("filename", ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json"]) +@pytest.mark.parametrize( + "filename", + ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json", "agent-bound-tdx.json"], +) def test_example_parses(filename: str) -> None: record = TrustRecord.model_validate(_load(filename)) assert record.eat_profile == "tag:agentrust.io,2026:trace-v0.1" @@ -126,6 +129,95 @@ def test_subject_rejects_http_scheme() -> None: TrustRecord.model_validate(data) +# Optional agent-identity block (spec §3.1.1, issue #33) + + +def test_agent_block_absent_is_valid() -> None: + """Records without an agent block remain valid (backward compatible).""" + record = TrustRecord.model_validate(_load("intel-tdx.json")) + assert record.agent is None + + +def test_agent_block_present_parses() -> None: + data = _load("intel-tdx.json") + data["agent"] = { + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + "binding": "svid-matched", + } + record = TrustRecord.model_validate(data) + assert record.agent is not None + assert record.agent.agent_id.startswith("spiffe://") + assert record.agent.manifest_id == "0197739a-8c00-7000-8000-000000000001" + assert record.agent.binding == "svid-matched" + + +def test_agent_id_rejects_http_scheme() -> None: + data = _load("intel-tdx.json") + data["agent"] = {"agent_id": "https://example.org/agent", "manifest_id": "abc"} + with pytest.raises(ValidationError): + TrustRecord.model_validate(data) + + +def test_agent_block_extra_field_rejected() -> None: + data = _load("intel-tdx.json") + data["agent"] = { + "agent_id": "spiffe://factory.example/agent/x", + "manifest_id": "abc", + "unexpected": "x", + } + with pytest.raises(ValidationError): + TrustRecord.model_validate(data) + + +def test_agent_block_requires_agent_id_and_manifest_id() -> None: + """A present agent block must carry both binding fields; partial is rejected (#33).""" + base = _load("intel-tdx.json") + partials = ( + {}, + {"binding": "svid-matched"}, + {"agent_id": "spiffe://x/a"}, + {"manifest_id": "abc"}, + ) + for partial in partials: + data = {**base, "agent": partial} + with pytest.raises(ValidationError): + TrustRecord.model_validate(data) + + +def test_agent_id_may_equal_subject() -> None: + """The spec permits subject == agent.agent_id; it must not be rejected (#33).""" + data = _load("intel-tdx.json") + data["agent"] = { + "agent_id": data["subject"], + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + } + record = TrustRecord.model_validate(data) + assert record.agent.agent_id == record.subject + + +def test_agent_id_accepts_did_uri() -> None: + """agent_id accepts a DID URI, not only SPIFFE (#33).""" + data = _load("intel-tdx.json") + data["agent"] = { + "agent_id": "did:web:factory.example", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + } + record = TrustRecord.model_validate(data) + assert record.agent.agent_id.startswith("did:") + + +def test_agent_block_binding_optional() -> None: + """binding is optional; agent_id + manifest_id alone is valid (#33).""" + data = _load("intel-tdx.json") + data["agent"] = { + "agent_id": "spiffe://factory.example/agent/x", + "manifest_id": "abc", + } + record = TrustRecord.model_validate(data) + assert record.agent.binding is None + + def test_okp_jwk_without_key_material_rejected() -> None: """An OKP confirmation key with no crv/x carries no key material and binds nothing.""" data = _load("intel-tdx.json") diff --git a/tests/test_sign.py b/tests/test_sign.py index a95ca43..102051c 100644 --- a/tests/test_sign.py +++ b/tests/test_sign.py @@ -1,7 +1,6 @@ """Tests for agentrust_trace.sign.""" import base64 -import json import pytest from cryptography.exceptions import InvalidSignature diff --git a/tests/test_validate.py b/tests/test_validate.py index c409ac4..8e5f828 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -15,7 +15,10 @@ def _load(name: str) -> dict: return json.loads((EXAMPLES_DIR / name).read_text()) -@pytest.mark.parametrize("filename", ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json"]) +@pytest.mark.parametrize( + "filename", + ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json", "agent-bound-tdx.json"], +) def test_examples_pass_json_schema(filename: str) -> None: validate_json(_load(filename))