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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ agt-evidence.json
agt-attestation.json
sbom.spdx.json
scorecard-results.sarif

# Generated example output (ephemeral keys + timestamps)
examples/trace-dag/dag.json
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ built/stubbed boundary.
- AGT governance gate is now **blocking** (was advisory): CI installs `agent-governance-toolkit[full]`, so the OWASP ASI 2026 coverage modules load and `agt verify` reports 10/10 coverage with 6/6 runtime checks (COMPLETE). The enforcement descriptor now declares cA2A's `governed_capabilities`, reported as the registered-tools inventory. A governance or coverage regression now fails CI.
- Real Cedar policy engine binding: `ca2a_runtime.cedar.CedarPolicy` (backed by `cedarpy`, the engine cMCP runs) evaluates each capability as a Cedar authorization request. A new `ca2a_runtime.policy.Policy` protocol makes `LocalPolicy` (allow set) and `CedarPolicy` interchangeable in the peer path. Adds the `cedarpy` dependency.
- Transport-agnostic inbound peer request handler: `ca2a_runtime.peer.handle_peer_request` with `PeerRequest` / `PeerResult`. Composes the full pipeline (verify chain, intersect scope and enforce, open a sealed payload with the enclave key, emit a linked provenance record) fail-closed. A transport parses its wire format into a `PeerRequest`; cA2A does not define the transport (profile, not protocol).
- TRACE binding for the delegation DAG (Tier 2): `ca2a_runtime.trace_binding` lifts each delegation hop into a signed TRACE Trust Record carrying the A2A profile `delegation` block (`build_trace_record`, `sign_trace_record`, `emit_dag`, `trace_record_hash`, `HopContext`). Records are produced and signed with `agentrust-trace` (Ed25519 over RFC 8785), so TRACE canonicalization and signing are reused, not reimplemented. `ca2a_verify.verify_trace_dag` verifies a signed root-to-leaf DAG offline (structural validity, trusted-key signature, unbroken parent links over the full signed parent record) and `cross_check_trace_dag` ties it to the delegation chain; new error `TRACE_RECORD_INVALID`. Software-mode records are Level 0 (platform `software-only`); a hardware TEE run is what lifts them to Level 1. Adds the `agentrust-trace` dependency (and `agentrust-trace-tests` for dev). See `examples/trace-dag/`.
- RFC 8785 (JSON Canonicalization Scheme) canonicalization: `ca2a_runtime.canonical.canonicalize`. Credential and provenance bodies are now signed over the JCS encoding (UTF-16 key ordering, JCS string escaping, literal non-ASCII, shortest-decimal integers), so cA2A signatures are cross-verifiable with agent-manifest. ASCII credentials are byte-identical to the previous encoding, so existing signatures still verify.
- Repository scaffold: governance, CI/CD, docs framework, and packaging at parity with the agentrust-io house standard

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple

- Runtime peer-delegation enforcement: **decision core landed** (`ca2a_runtime.peer.enforce_peer_call`: verify chain, intersect delegated scope with local policy, enforce, emit provenance record; claim C3 validated), now with a **real Cedar policy engine** option (`ca2a_runtime.cedar.CedarPolicy`) alongside the allow-set `LocalPolicy`. Remaining: wire the decision core to a live A2A transport (Tier 2)
- Sealed peer channel: **landed** (`ca2a_runtime.channel`: HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing to the peer's attested key; claim C4 validated). Remaining: bind the seal to a verified attestation report on a live call, and rely on the enclave to hold the private key (hardware property)
- Linked runtime evidence: each hop's TRACE record references the parent record hash and delegation credential id, producing a verifiable delegation DAG (Tier 2)
- Linked runtime evidence: **landed** (`ca2a_runtime.trace_binding` emits a signed TRACE record per hop with the A2A `delegation` block; `ca2a_verify.verify_trace_dag` verifies the DAG offline, each link committing to the parent's full signed record). Built on `agentrust-trace` (Ed25519 + RFC 8785), reused not reimplemented. Software-mode records are Level 0; a hardware TEE run lifts them to Level 1. See `examples/trace-dag/`.

## Critical path, sequenced first (Tier 3)

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/trace-a2a-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ This is done offline, from signed records alone, without trusting the operators

## Status

The delegation-link field is on the trace-spec roadmap as the "A2A profile, pending A2A protocol stability." A2A is now stable at v1.x, which clears that blocker. The DAG verifier lands with the Tier 2 provenance work in this repo. See [ROADMAP.md](../../ROADMAP.md).
The delegation-link field is on the trace-spec roadmap as the "A2A profile, pending A2A protocol stability." A2A is now stable at v1.x, which clears that blocker. The `delegation` block ships in the TRACE v0.1 schema. cA2A emits and verifies the DAG today: `ca2a_runtime.trace_binding` produces a signed TRACE record per hop with the block, and `ca2a_verify.verify_trace_dag` reconstructs and verifies the DAG offline, each `parent_record_hash` committing to the parent's full signed record. Software-mode records are TRACE Level 0 (platform `software-only`); an end-to-end run on confidential-computing hardware is what lifts them to Level 1. See [ROADMAP.md](../../ROADMAP.md) and `examples/trace-dag/`.
36 changes: 36 additions & 0 deletions examples/trace-dag/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# TRACE delegation DAG

Emit a signed [TRACE](https://github.com/agentrust-io/trace-spec) Trust Record per
delegation hop, linked into a verifiable DAG via the A2A profile's `delegation`
block, then verify the DAG offline.

```bash
python examples/trace-dag/demo.py
```

The demo builds a three-hop chain (orchestrator to researcher to retriever),
lifts each hop into a TRACE record, verifies the DAG from the signed records
alone, cross-checks it against the delegation chain, confirms each record passes
the TRACE conformance suite at **Level 0**, and writes `dag.json`.

This is the **software-attestation** path (`runtime.platform` = `software-only`),
so the records are honestly Level 0. The appraisal and transparency URIs use the
reserved `.invalid` TLD so a software-mode record can never be mistaken for a
hardware-attested one. A run on confidential-computing hardware (a real TEE
measurement) is what lifts these records to Level 1.

## What each piece does

- `ca2a_runtime.trace_binding.emit_dag` — build and sign the linked records.
- `ca2a_verify.verify_trace_dag` — verify the DAG offline against trusted keys:
each record valid and signed, and each `delegation.parent_record_hash` equal to
the hash of the parent's **full signed record**.
- `ca2a_verify.cross_check_trace_dag` — tie the DAG to the delegation chain
(each non-root hop acted under the credential the chain names).

Validate a single record with the TRACE tooling directly:

```bash
python -c "import json; json.dump(json.load(open('examples/trace-dag/dag.json'))[1], open('rec.json','w'))"
trace-tests verify --record rec.json --level 0
```
101 changes: 101 additions & 0 deletions examples/trace-dag/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Emit a TRACE delegation DAG from a cA2A chain and verify it offline.

Run: python examples/trace-dag/demo.py

Builds a three-hop delegation chain (A -> B -> C), lifts each hop into a signed
TRACE Trust Record carrying the A2A ``delegation`` block, verifies the resulting
DAG from the signed records alone, cross-checks it against the chain, confirms
each record passes the TRACE conformance suite at Level 0, and writes the DAG to
``dag.json``. This is the software-attestation path: records are honestly Level 0
(platform ``software-only``); a hardware TEE run is what lifts them to Level 1.
"""

from __future__ import annotations

import json
import time
from pathlib import Path

from agentrust_trace import generate_key
from trace_tests.runner import run as run_conformance

from ca2a_runtime.delegation import DelegationCredential, new_keypair
from ca2a_runtime.trace_binding import HopContext, HopSpec, digest, emit_dag
from ca2a_verify import cross_check_trace_dag, verify_trace_dag

SUBJECTS = [
"spiffe://ca2a.example/agent/orchestrator",
"spiffe://ca2a.example/agent/researcher",
"spiffe://ca2a.example/agent/retriever",
]
SCOPES = [
frozenset({"task:read", "task:write", "tool:search"}),
frozenset({"task:read", "tool:search"}),
frozenset({"tool:search"}),
]


def build_chain(scopes: list[frozenset[str]]) -> list[DelegationCredential]:
"""A correctly signed, narrowing root-to-leaf chain (one hop per scope)."""
chain: list[DelegationCredential] = []
priv, pub = new_keypair()
parent_id: str | None = None
for depth, scope in enumerate(scopes):
next_priv, next_pub = new_keypair()
cred = DelegationCredential(
credential_id=f"cred-{depth}",
issuer=pub,
subject=next_pub,
scope=scope,
depth=depth,
parent_id=parent_id,
).sign(priv)
chain.append(cred)
parent_id = cred.credential_id
priv, pub = next_priv, next_pub
return chain


def main() -> None:
now = int(time.time())
chain = build_chain(SCOPES)
keys = [generate_key() for _ in chain]

hops = [
HopSpec(
subject=SUBJECTS[i],
signing_key=keys[i],
context=HopContext.software(
model_provider="anthropic",
model_id="claude-opus-4-8",
image_label=f"ca2a-peer:{SUBJECTS[i].rsplit('/', 1)[-1]}",
policy_bundle_hash=digest(b"demo-policy-bundle"),
),
iat=now,
credential_id=cred.credential_id,
)
for i, cred in enumerate(chain)
]

records = emit_dag(hops)
print(f"emitted {len(records)} linked TRACE records (root -> leaf)")

trusted = [k.public_key() for k in keys]
result = verify_trace_dag(records, trusted_keys=trusted)
cross_check_trace_dag(records, chain)
print(f"DAG verified offline: {result.hops} hops, {result.root_subject} -> {result.leaf_subject}")

for i, record in enumerate(records):
findings = run_conformance(record, "trace", level=0)
failed = [f for group in findings.values() for f in group if f.failed()]
status = "PASS" if not failed else f"FAIL {failed}"
block = "root (no delegation block)" if i == 0 else record["delegation"]["credential_id"]
print(f" record {i}: TRACE Level 0 {status} [{block}]")

out = Path(__file__).parent / "dag.json"
out.write_text(json.dumps(records, indent=2), encoding="utf-8")
print(f"wrote {out}")


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ dependencies = [
"cryptography>=42.0",
"pyyaml>=6.0",
"cedarpy>=4.8",
# TRACE record models + RFC 8785 signing, reused rather than reimplemented
# (see ROADMAP Tier 0). Pin a compatible minor; cross-repo skew is a known
# risk documented in LIMITATIONS.md.
"agentrust-trace>=0.3,<0.4",
"rfc8785>=0.1",
]

[project.optional-dependencies]
Expand All @@ -39,6 +44,8 @@ dev = [
"types-pyyaml",
"bandit[toml]>=1.7",
"pip-audit>=2.6",
# Conformance suite: asserts emitted records pass the TRACE Level checks.
"agentrust-trace-tests>=0.2,<0.3",
]

[project.scripts]
Expand Down Expand Up @@ -73,6 +80,7 @@ ignore = ["E501"]
"src/ca2a_runtime/cli.py" = ["T201"]
"scripts/gen_example_chain.py" = ["T201"]
"scripts/gen_agt_evidence.py" = ["T201"]
"examples/**/*.py" = ["T201"]

[tool.bandit]
skips = ["B101"]
Expand Down
12 changes: 12 additions & 0 deletions src/ca2a_runtime/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,15 @@ class TransportError(CA2AError):

code = "TRANSPORT_ERROR"
http_status = 400


class TraceRecordInvalid(CA2AError):
"""A TRACE record is structurally invalid or its signature does not verify.

Raised by the TRACE DAG verifier when a hop's record fails schema validation,
is signed by a key that is not trusted, or its embedded key does not match the
one supplied. Distinct from ProvenanceLinkBroken, which covers the parent-link
chaining between otherwise valid records."""

code = "TRACE_RECORD_INVALID"
http_status = 422
Loading