Skip to content
Open
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
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/

# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions examples/agent-bound-tdx.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
23 changes: 23 additions & 0 deletions schema/trace-claim.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
43 changes: 43 additions & 0 deletions spec/trace-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

<!-- CHANGED: #33 — optional agent-identity block, distinct from subject -->

`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/<uuid>`). 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).
Expand Down Expand Up @@ -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.

<!-- CHANGED: #33 — optional offline agent-identity cross-check -->

**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.
Expand Down Expand Up @@ -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)?

---

Expand Down
2 changes: 2 additions & 0 deletions src/agentrust_trace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""agentrust-trace — TRACE Trust Record models, validation, and signing."""

from agentrust_trace.models import (
AgentIdentity,
Appraisal,
BuildProvenance,
ConfirmationKey,
Expand Down Expand Up @@ -28,6 +29,7 @@

__all__ = [
"__version__",
"AgentIdentity",
"Appraisal",
"BuildProvenance",
"ConfirmationKey",
Expand Down
18 changes: 18 additions & 0 deletions src/agentrust_trace/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/agentrust_trace/schema/trace-v0.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
94 changes: 93 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
1 change: 0 additions & 1 deletion tests/test_sign.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Tests for agentrust_trace.sign."""

import base64
import json

import pytest
from cryptography.exceptions import InvalidSignature
Expand Down
Loading
Loading