From b781c21bdc6235a6c42a95c9f2ad1ea0feb4cfe5 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:19:25 +0000 Subject: [PATCH 1/8] rfc: propose delegation-link verification, with the vectors that argue it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `delegation` block is normative in v0.2 and nothing says what a verifier does with a chain of them. `spec/trace-v0.2.md` never mentions `parent_record_hash` or `credential_id`; the only prose is one sentence in `docs/schema.md`, and every operative term in it is open — which bytes the digest covers, what "the delegation chain" is when no credential object exists in the schema, what a verifier does with a link it cannot compute. Two implementations can satisfy every constraint the repository states today and agree on nothing. `docs/rfcs/a2a-delegation-profile.md` proposes ten rules over the fields that already exist, so adopting it requires no schema change. `examples/delegation-link/` carries 23 vectors that score an implementation against them. Requirement keywords in the RFC are lowercase on purpose: a proposal that writes itself in the imperative is a specification nobody agreed to. Three forks in the current text had to be settled before a single vector could be written, and each is recorded with its reason rather than assumed: The digest covers the complete parent record, signature included. A digest over the signed body alone does not bind the parent's *signer* — anyone may re-sign identical bytes under another key and satisfy the child's commitment — so the child would have committed to what its parent said and not to who said it. Vector 05 is a complete, correctly signed chain whose only defect is which bytes its link was computed over. There is no cycle rule. A cycle needs each record's block to carry a digest covering the block that names it back, which is a hash collision; a rule against it would be untestable by construction. The reachable analogue is an unbounded chain, and that is the only reason the depth bound exists. Stated so a reader can tell which of the two was decided and which was forgotten. A link naming a digest algorithm the verifier cannot compute makes the chain unverifiable, not invalid. Reporting `parent_not_found` for it would be a finding nobody made: the verifier did not fail to find the parent, it did not look. This is the delegation-surface instance of the semantics merged in `docs/verification.md`, and `parent_not_found` is explicitly guarded on algorithm support so the two cannot be produced together for one link. Coverage is held to #124's discipline from the first vector rather than as a later hardening pass: two load-bearing vectors per rule, and for every rule at least one declared implementation defect that one vector catches and the other misses. All ten defects model a real shortcut — verifying the leaf only, anchoring on any trusted key found, an off-by-one bound, case-insensitive lookup of an opaque identifier, issuer and holder compared to the wrong ends of the hop, half a validity window, narrowing checked at one hop, the link algorithm read once and assumed uniform. Two of those declarations found faults in the walk while it was being written, which is the argument for declaring them rather than asserting margin and stopping. The walk's break condition originally repeated the depth comparison, so a weakened bound never got to walk further than a correct one and both depth vectors moved together under every mutation — margin without independence. And an earlier vector 09 put an untrusted root three hops down, which no defect could separate from vector 08; the version that separates them places a *trusted* key partway up the chain, which is the shortcut an implementation actually takes. Reproducibility is a property of the corpus, not a courtesy. Keys derive from one published seed by role label. `tests/test_generators_reproduce_fixtures.py` (#171) discovered the generator with no new guard code and holds it to byte reproduction with no entry in the `NOT_GENERATED` ledger, which is the bar #178 proposes for the repository's corpora. Every record in every vector, including the ones built to fail, validates against `schema/trace-claim.json`: a defect the schema already rejects is not a profile defect, and a rule that looks covered only because its vector is malformed in some louder way is not covered. Nothing enters the package's public API. The walk lives in `tests/`, beside the action-receipt verifier it is modelled on, because the rules it implements are not normative yet. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- CHANGELOG.md | 2 + docs/rfcs/a2a-delegation-profile.md | 251 +++++++ .../delegation-link/01-valid-single-hop.json | 132 ++++ .../02-valid-full-depth-out-of-order.json | 246 +++++++ .../delegation-link/03-valid-root-only.json | 94 +++ .../04-parent-record-absent.json | 210 ++++++ .../05-link-over-signed-body.json | 134 ++++ .../06-leaf-signed-by-other-key.json | 134 ++++ .../07-intermediate-signed-by-other-key.json | 172 +++++ .../08-root-key-untrusted.json | 116 ++++ .../09-trusted-key-below-the-root.json | 160 +++++ .../10-credential-not-registered.json | 134 ++++ .../11-credential-id-case-differs.json | 134 ++++ .../12-credential-issued-by-third-party.json | 116 ++++ .../13-credential-self-issued.json | 116 ++++ .../14-credential-held-by-third-party.json | 116 ++++ .../15-credential-holder-is-the-parent.json | 116 ++++ .../16-credential-expired-at-hop.json | 116 ++++ .../17-credential-not-yet-valid-at-hop.json | 116 ++++ .../18-data-class-widened-at-leaf.json | 134 ++++ .../19-data-class-widened-mid-chain.json | 210 ++++++ .../20-depth-far-past-the-bound.json | 336 +++++++++ .../21-depth-one-past-the-bound.json | 292 ++++++++ .../22-leaf-link-uses-sha384.json | 134 ++++ .../23-deep-link-uses-sha384.json | 210 ++++++ examples/delegation-link/README.md | 77 +++ .../delegation-link/gen_delegation_vectors.py | 637 ++++++++++++++++++ tests/delegation_margins.json | 12 + tests/test_delegation_completeness.py | 403 +++++++++++ tests/test_delegation_vectors.py | 559 +++++++++++++++ 30 files changed, 5619 insertions(+) create mode 100644 docs/rfcs/a2a-delegation-profile.md create mode 100644 examples/delegation-link/01-valid-single-hop.json create mode 100644 examples/delegation-link/02-valid-full-depth-out-of-order.json create mode 100644 examples/delegation-link/03-valid-root-only.json create mode 100644 examples/delegation-link/04-parent-record-absent.json create mode 100644 examples/delegation-link/05-link-over-signed-body.json create mode 100644 examples/delegation-link/06-leaf-signed-by-other-key.json create mode 100644 examples/delegation-link/07-intermediate-signed-by-other-key.json create mode 100644 examples/delegation-link/08-root-key-untrusted.json create mode 100644 examples/delegation-link/09-trusted-key-below-the-root.json create mode 100644 examples/delegation-link/10-credential-not-registered.json create mode 100644 examples/delegation-link/11-credential-id-case-differs.json create mode 100644 examples/delegation-link/12-credential-issued-by-third-party.json create mode 100644 examples/delegation-link/13-credential-self-issued.json create mode 100644 examples/delegation-link/14-credential-held-by-third-party.json create mode 100644 examples/delegation-link/15-credential-holder-is-the-parent.json create mode 100644 examples/delegation-link/16-credential-expired-at-hop.json create mode 100644 examples/delegation-link/17-credential-not-yet-valid-at-hop.json create mode 100644 examples/delegation-link/18-data-class-widened-at-leaf.json create mode 100644 examples/delegation-link/19-data-class-widened-mid-chain.json create mode 100644 examples/delegation-link/20-depth-far-past-the-bound.json create mode 100644 examples/delegation-link/21-depth-one-past-the-bound.json create mode 100644 examples/delegation-link/22-leaf-link-uses-sha384.json create mode 100644 examples/delegation-link/23-deep-link-uses-sha384.json create mode 100644 examples/delegation-link/README.md create mode 100644 examples/delegation-link/gen_delegation_vectors.py create mode 100644 tests/delegation_margins.json create mode 100644 tests/test_delegation_completeness.py create mode 100644 tests/test_delegation_vectors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 607fcc1..7d81d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ### Added +- **A verification profile is proposed for the `delegation` block, with the conformance material to argue it against.** The block is normative in v0.2 and nothing says what a verifier does with a chain of them: `spec/trace-v0.2.md` never mentions `parent_record_hash` or `credential_id`, and the one descriptive sentence in `docs/schema.md` leaves every operative term open, so two conforming implementations can agree on nothing. [`docs/rfcs/a2a-delegation-profile.md`](docs/rfcs/a2a-delegation-profile.md) proposes ten rules over the fields that already exist — no schema change — and `examples/delegation-link/` carries 23 vectors that score an implementation against them. Three forks in the current text had to be settled before any vector could be written, and each is stated with the reason rather than assumed: the digest covers the complete parent record including its signature, because a digest of the signed body alone does not bind the parent's signer; there is no cycle rule, because a delegation cycle is a hash collision and the reachable analogue is an unbounded chain, which is what the depth bound is for; and a link naming a digest algorithm the verifier cannot compute makes the chain unverifiable rather than invalid, which is the delegation-surface instance of the semantics already merged in `docs/verification.md`. Nothing here binds an implementation until the profile is adopted. Targets the v0.3 A2A profile named in [`ROADMAP.md`](ROADMAP.md). + - **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50). ### Security diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md new file mode 100644 index 0000000..388a90a --- /dev/null +++ b/docs/rfcs/a2a-delegation-profile.md @@ -0,0 +1,251 @@ +# RFC Proposal: A2A delegation-link verification profile + +**Status:** Draft proposal. Binds nothing. +**Scope:** Verification rules for the existing `delegation` block. No schema change. +**Target:** `spec/trace-v0.2.md` §3.1 surface, for the v0.3 A2A profile named in `ROADMAP.md`. +**Conformance material:** `examples/delegation-link/` — 23 vectors, generator, published key. + +Requirement keywords are lowercase throughout this document, deliberately. `CONTRIBUTING.md` +draws the line that normative text lives in the specifications and informative text binds no +implementation; this file is informative until its rules are adopted, at which point they +become uppercase in `spec/` and this file becomes a pointer to where they went. A proposal +that writes itself in the imperative is a specification nobody agreed to. + +--- + +## 1. What exists today + +The `delegation` block is normative in v0.2. `schema/trace-claim.json` requires +`parent_record_hash` and `credential_id`, pins the first to `sha256:`/`sha384:` and the +second to a non-empty string, and forbids additional members. + +What a verifier does with a chain of them is not normative anywhere. +`spec/trace-v0.2.md` does not mention either field. The only prose is `docs/schema.md`: + +> A chain of records linked this way forms an offline-verifiable delegation DAG: a verifier +> walks `parent_record_hash` from a leaf record back to the root and confirms each hop acted +> under a credential in the delegation chain. + +One sentence, and every operative word in it is undefined. What are the bytes the digest +covers. What is "the delegation chain" that a credential is in, given that no credential +object exists in the schema. What makes a hop's use of one legitimate. What a verifier does +when it cannot compute the digest algorithm the link names. + +Two conforming implementations can satisfy every constraint the repository states today and +agree on nothing. This proposal is the smallest set of rules that closes that, written +against the block as it stands so that adopting it requires no schema change. + +## 2. The verification model + +A verifier is given a **record set**, a **leaf**, and **context**. The record set is a set: +it arrives in no defined order, and the leaf is named by digest rather than by position. +An implementation that reads the first element as the root, or the next element as the +parent, is reading a property of its input channel rather than of the evidence. + +The verifier walks from the leaf towards the root, resolving each `parent_record_hash` +against the digests of the records it holds. The walk ends at a record with no `delegation` +block — the root — or at a link it could not follow. + +Context is what a verifier knows that no record can tell it, and it is enumerated rather +than assumed: + +| Context | Why it cannot come from the records | +|---|---| +| `trusted_root_keys` | A record naming its own key as trusted is not evidence. | +| `credentials` | `credential_id` is an identifier; the thing it identifies is held out of band. | +| `data_class_lattice` | `data_class` is an open string in the schema, so no ordering can be inferred from a record. | +| `max_depth` | A bound is a deployment decision, not a property of a chain. | +| `supported_digest_algorithms` | What the verifier can compute, which is not what the chain may name. | +| `now` | Present for completeness; §4.3 explains why no rule reads it. | + +Each entry is a place where an implementation that guesses instead of being told produces +an answer that looks like verification and is not. Naming them is half the profile. + +## 3. The rules + +Ten rules, each with a stable code, a classification, and two independent vectors. +Classification is the three-way distinction the conformance corpus in `agentrust-io/ca2a` +already uses for its action cases: a structural failure, an authority failure, and a chain +that could not be read are three different findings, and a verifier that collapses any two +of them is reporting something other than what it found. + +| # | Code | Class | Rule | Vectors | +|---|---|---|---|---| +| D-1 | `record_signature_invalid` | provenance | Every record on the walked chain verifies under the key it advertises in `cnf.jwk`. | 06, 07 | +| D-2 | `root_key_untrusted` | provenance | The record with no `delegation` block carries a key in `trusted_root_keys`. | 08, 09 | +| D-3 | `parent_not_found` | provenance | Each `parent_record_hash` resolves to a record in the set, under §4.1's preimage. | 04, 05 | +| D-4 | `depth_exceeded` | authorization | The walk follows no more than `max_depth` links. | 20, 21 | +| D-5 | `credential_unknown` | authorization | `credential_id` matches a registry entry as an exact octet string. | 10, 11 | +| D-6 | `credential_issuer_mismatch` | authorization | The credential's issuer is the parent record's `subject`. | 12, 13 | +| D-7 | `credential_holder_mismatch` | authorization | The credential's holder is this record's `subject`. | 14, 15 | +| D-8 | `credential_window` | authorization | This record's `iat` falls inside the credential's validity window. | 16, 17 | +| D-9 | `data_class_widened` | authorization | A hop's `data_class` is no more sensitive than its parent's, per the supplied lattice. | 18, 19 | +| D-10 | `digest_algorithm_unsupported` | unverifiable | A link naming an algorithm the verifier cannot compute yields an unverifiable chain. | 22, 23 | + +Provenance outranks authorization in the reported classification. A chain whose structure is +broken has no established parent for a credential to be judged against, so reporting an +authority failure over it would describe a relationship that was never demonstrated. + +D-6 and D-7 together are the whole of what "acted under a credential in the delegation +chain" can mean offline with the fields that exist: the credential came *from* the hop above +and was issued *to* this one. Vectors 13 and 15 are the two ways an implementation gets that +backwards while every comparison it makes still returns agreement. + +## 4. Three decisions a vector could not be written without + +These are not refinements. Each is a fork where the existing text supports both branches, +and no conformance material can exist until one is chosen. + +### 4.1 The digest covers the complete record, signature included + +"Digest of the parent hop's Trust Record" does not say which bytes. Over the RFC 8785 +encoding of the complete record, or over the signed body with `signature` removed? Both are +natural readings and they are not interoperable: a chain built under one is a chain of +dangling links under the other, and neither side has a diagnostic that says so. + +This profile takes the complete record, because the alternative does not bind the parent's +signer. Under the body reading, a child's `parent_record_hash` commits to bytes that any +holder of any key can re-sign. Two records with different signatures — one genuine, one +issued by an attacker — satisfy the same commitment, and a verifier walking the chain +resolves to whichever it was handed. The child would have committed to *what its parent +said* and not to *who said it*, which is the entire content of a provenance link. + +The complete-record reading has a consequence worth stating, because it changes what an +attacker can reach: every ancestor's bytes are committed to by its child, so no record on a +chain can be altered in place except the leaf. An ancestor with an invalid signature is +still reachable — it has to be built that way before its child signs — which is why D-1 +applies to every record on the walk and not only to the leaf. Vector 07 is that case, and +vector 06 is the leaf case; an implementation that verifies the leaf and takes the rest on +the strength of the hashes passes one and fails the other. + +Vector 05 is this decision and nothing else: a complete, correctly signed two-record chain +whose only defect is that its link was computed over the parent's signed body. + +### 4.2 No cycle rule, and why the depth bound is not a substitute for one + +A delegation cycle would need record A's block to carry a digest of B while B's block +carries a digest of A. Each digest covers the block holding the other, so constructing the +pair is a hash collision. Cycles are not forbidden by this profile; they are unreachable, +and a rule against them would be untestable by construction — there is no vector that could +demonstrate an implementation lacking it. + +The reachable analogue is an *unbounded* chain, which is trivially constructible, and a walk +without a limit is a denial of service on the verifier. That is the only reason D-4 exists, +and it is stated here so that a reader can tell which of the two was decided and which was +forgotten. + +Vectors 20 and 21 pin the comparison from both sides: 21 sits one past the bound, and vector +02 sits exactly on it and verifies. A bound tested only from above accepts an off-by-one; a +bound tested only from below rejects legitimate chains at the limit. + +### 4.3 A link that cannot be read is unverifiable, not invalid + +The schema permits `sha384:` links. A verifier that implements only `sha256:` cannot resolve +such a link — and reporting `parent_not_found` for it would be a finding nobody made. The +verifier did not fail to find the parent; it did not look. + +This follows the semantics already merged in `docs/verification.md`: evidence that resolves +and contradicts fails the appraisal, and a verifier does not downgrade to escape that; +evidence that does not resolve downgrades honestly, and the verifier records the depth it +actually achieved. D-10 is the delegation-surface instance of the second half. In the +reference walk, D-3 is explicitly guarded on algorithm support so that the two findings +cannot be produced together for the same link. + +Vector 23 is why this needs a rule rather than a note: a chain whose leaf link is `sha256:` +and whose third link is `sha384:` reports "verified" from any implementation that reads the +algorithm once and assumes the chain is uniform, having never resolved half of it. + +The related decision, made for the same reason: D-8 judges the credential window against the +hop's own `iat`, not against the verifier's `now`. A chain does not become invalid because +it is being read late, and a verifier using its own clock returns a different answer every +day for the same evidence. `now` stays in the context table because a deployment may impose +freshness policy on top of this profile; no rule here reads it. + +## 5. The conformance corpus + +`examples/delegation-link/` holds 23 vectors. Each is one scenario: a complete record set, +the verifier context to judge it under, and the classification and codes it expects, so a +third party can score an implementation without running anything from this repository. + +Every record in every vector — including the ones built to fail — validates against +`schema/trace-claim.json`. A defect the schema already rejects is not a profile defect, and a +rule that appears covered only because its vector is malformed in some louder way is not +covered. + +Reproducibility is a property of the corpus, not a courtesy. Keys derive from one published +seed by role label; the generator regenerates every byte; `tests/test_generators_reproduce_fixtures.py` +(#171) discovered it with no new guard code and holds it to that with no entry in the +`NOT_GENERATED` ledger. This is the bar issue #178 proposes for the repository's corpora, +adopted here from the first vector rather than retrofitted. + +Coverage is held to the discipline argued in #124 and executed in +`tests/test_vector_completeness.py`: two load-bearing vectors per rule, and — the part that +is not satisfied by writing the same vector twice — at least one declared implementation +defect that one vector catches and the other misses. `tests/test_delegation_completeness.py` +declares those defects, all ten of them modelling a real shortcut: verifying the leaf only, +anchoring on any trusted key found, an off-by-one bound, case-insensitive identifier lookup, +issuer and holder compared to the wrong ends, half a validity window, narrowing checked at +one hop. + +Vector identifiers are `TRACE-DELEG-NNN` and are never reused. They are deliberately not +`ACTION-*`: that namespace belongs to ca2a's conformance set, whose own rule is that its +identifiers are never reused, and borrowing it from another repository would collide the +first time either side adds a case. §7 proposes a cross-reference table instead. + +## 6. What this proposal does not do + +- **No schema change.** Every rule reads fields that exist in v0.2. +- **No credential format.** The registry is verifier context, like a trusted key set. What a + credential *is*, how it is issued, signed, or revoked, is out of scope, and the vectors + express one only as the fields the rules read. +- **No revocation.** A revoked credential is indistinguishable here from a valid one, which + is a real gap and belongs in the same discussion as the credential format. +- **No withdrawal before execution.** An authorization revoked between issue and use is one + of the two gaps issue #66 has already named on the approval-shaped surface; it needs a + field this schema does not have. +- **No authority-epoch staleness**, the other #66 gap, for the same reason. +- **No cross-verifier agreement.** This is the load-bearing omission and §7 is about it. + +## 7. The part that makes this worth doing + +One implementation passing its own conformance corpus is self-agreement. What would make +this a profile rather than a local convention is the same vectors run through two +independently written verifiers. + +Both exist. `agentrust-io/ca2a` verifies delegation DAGs offline in `ca2a_verify`; +`agentrust-io/agent-manifest` exports `verify_delegation_chain`, `DelegationHopSigner` and +`delegation_depth_exceeded` from its public API. `ca2a/src/ca2a_runtime/delegation/credential.py` +states that its credentials are "cross-verifiable with agent-manifest". Nothing in any of the +three repositories tests that claim. + +The proposal for the next step, which is not this document's to decide: + +1. A cross-reference table between `TRACE-DELEG-*` here and ca2a's `ACTION-*` group, so an + implementation scored under one can be read against the other. +2. A harness running these vectors through both verifiers and asserting agreement on the + shared surface — signature validity, hop continuity, scope narrowing, depth. +3. Divergences are the output. Each is either an ambiguity in this text, which comes back + here as a vector and a paragraph, or a defect in one implementation, which is filed + where it lives. + +The same corpus would also close a gap on the agent-manifest side: of its 21 vectors, two +touch delegation and both are single-hop, so its own narrowing and depth logic has no vector +coverage at all. + +## 8. Open questions + +1. **Is the credential registry the right shape?** It is modelled here as verifier context + because nothing in the schema describes a credential. The alternative — a credential + object in the record — is a schema change and a much larger proposal. +2. **Should `data_class` narrowing be in this profile at all?** It is the only scope-like + comparison the current fields support, and it may belong with a general scope model + rather than with delegation. +3. **Is `max_depth` a profile constant or deployment context?** Context here, on the grounds + that a bound is a deployment decision, but a fixed floor would make chains portable + between verifiers in a way this does not. +4. **Where should the cross-verifier harness live** — ca2a's tests, agent-manifest's, or a + neutral repository? Each choice makes one implementation the host of the corpus that + scores it. +5. **Does the leaf need to be named at all?** It is context here. A record set with two + unreferenced records has two candidate leaves, and picking wrong verifies a different + thing than the caller asked about. diff --git a/examples/delegation-link/01-valid-single-hop.json b/examples/delegation-link/01-valid-single-hop.json new file mode 100644 index 0000000..fdf9b10 --- /dev/null +++ b/examples/delegation-link/01-valid-single-hop.json @@ -0,0 +1,132 @@ +{ + "id": "TRACE-DELEG-001", + "name": "valid-single-hop", + "description": "One delegation from a trusted root. The shortest chain the profile has anything to say about, and the case every rule below is a departure from.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/02-valid-full-depth-out-of-order.json b/examples/delegation-link/02-valid-full-depth-out-of-order.json new file mode 100644 index 0000000..9e52ec4 --- /dev/null +++ b/examples/delegation-link/02-valid-full-depth-out-of-order.json @@ -0,0 +1,246 @@ +{ + "id": "TRACE-DELEG-002", + "name": "valid-full-depth-out-of-order", + "description": "Four delegations, exactly on `max_depth`, narrowing at every hop, with the record set emitted leaf-first. Verifies. A verifier whose bound is off by one rejects this, which is the other half of vector 21.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/03-valid-root-only.json b/examples/delegation-link/03-valid-root-only.json new file mode 100644 index 0000000..59059f8 --- /dev/null +++ b/examples/delegation-link/03-valid-root-only.json @@ -0,0 +1,94 @@ +{ + "id": "TRACE-DELEG-003", + "name": "valid-root-only", + "description": "A record with no delegation block at all. Not a degenerate chain: a root execution is the ordinary case, and a verifier that requires the block to be present has broken every non-delegated record.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/04-parent-record-absent.json b/examples/delegation-link/04-parent-record-absent.json new file mode 100644 index 0000000..8c91bad --- /dev/null +++ b/examples/delegation-link/04-parent-record-absent.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-004", + "name": "parent-record-absent", + "description": "The chain is sound, and the record the third hop names is simply not in the set. Presenting a leaf without the hops that authorise it is the cheapest attack on a chain nobody walks to the end.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "parent_not_found" + ] + } +} diff --git a/examples/delegation-link/05-link-over-signed-body.json b/examples/delegation-link/05-link-over-signed-body.json new file mode 100644 index 0000000..5292db0 --- /dev/null +++ b/examples/delegation-link/05-link-over-signed-body.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-005", + "name": "link-over-signed-body", + "description": "Every record is present and correctly signed; the leaf's `parent_record_hash` is a digest of its parent's signed body with `signature` removed, rather than of the complete record. This is the one vector that separates the two readings of \"digest of the parent hop's Trust Record\": under the profile's reading the link resolves to nothing, under the rejected reading the whole chain verifies.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:169845502881464187359f61e4857aa1b663f8614c6764d1a3c890ee018d180f", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:90bfc2c5fb7a2e082252400055f450d252e6d2a8deb6129868bc0820b415069c", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "hPdHyrSkbMdYwothvxKKg9-PbyH0f0IErsHQx060sf3jeWicXwd99j4N-YZUbEIxgbizQ5TTaEtEYN80myKJCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "parent_not_found" + ] + } +} diff --git a/examples/delegation-link/06-leaf-signed-by-other-key.json b/examples/delegation-link/06-leaf-signed-by-other-key.json new file mode 100644 index 0000000..181addb --- /dev/null +++ b/examples/delegation-link/06-leaf-signed-by-other-key.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-006", + "name": "leaf-signed-by-other-key", + "description": "The leaf advertises the executor's key in `cnf.jwk` and is signed by the stranger's. The leaf is the only record in a chain whose bytes nothing else commits to, so it is the only one an attacker can reach in place.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:5542cbcb9318aa362a8e3ae717865d1ec023bb36b3cfbcf62284c006915900e4", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "EQzzIBgZleeYvHd2xljslD1GihKKw3A56dIHmdko6kzjPCTxS-qL0DfBPWq7Jkn40_ssnLICR-UfowTFkjs4Ag" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "record_signature_invalid" + ] + } +} diff --git a/examples/delegation-link/07-intermediate-signed-by-other-key.json b/examples/delegation-link/07-intermediate-signed-by-other-key.json new file mode 100644 index 0000000..90e2df5 --- /dev/null +++ b/examples/delegation-link/07-intermediate-signed-by-other-key.json @@ -0,0 +1,172 @@ +{ + "id": "TRACE-DELEG-007", + "name": "intermediate-signed-by-other-key", + "description": "The same defect one hop up, built in before the child committed to it, so the link still resolves and only the signature is wrong. A verifier that checks the leaf and trusts the rest of the chain because 'the hashes match' passes this and fails vector 06.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:bb857be837d7c7043a047ed627508ee0d604e7234aff8c6dce3e7c65df546fff", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:8536dcabc3df38275f477505d997303eda99b5bd2415ab7d7c692808b005921d", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "zr4_ISnv0Lhz0cP2vx07e_jr7wLd_BmU5MQtOLcIlnnnppJhL6Z4Lsrn9er9P0JHKU9VUTKg7vYEuU-GQKYNBA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "boX6sOSimP9il6rQX-jXE3pB0dms_GIPYKAOaPNOCZEcfEBnmiUo1eiRqtZcX-rThGGqWJ_GBXi9-jGnovr4Aw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "record_signature_invalid" + ] + } +} diff --git a/examples/delegation-link/08-root-key-untrusted.json b/examples/delegation-link/08-root-key-untrusted.json new file mode 100644 index 0000000..22f57b2 --- /dev/null +++ b/examples/delegation-link/08-root-key-untrusted.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-008", + "name": "root-key-untrusted", + "description": "A correctly signed, internally consistent single-hop chain whose root is held by a key the verifier was never given. Every hash matches and every signature verifies; the chain is anchored to nothing.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:c73150297c588dc01ef579b527182c932e7f86499831d162c331b34495f19b58", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:stranger-to-planner": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:ceda95720fbfdf66be21cc2f866dd8d4004b1d15873ec461ad343845769da570", + "credential_id": "cred:stranger-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "RSdSUVplb1ShCA_RwqXys2OyNsOf-c4kmXwOFe_JDBzFBPZt0XyNoKhceX1a8FxcOsgQZtf-c9vQWqy9da_WAQ" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "_TJMhmc0FvvJlaI_nDsLUmSOJrjBiKqbNj16qh___YMAoWtCsx1LZE6kbfd61qoDcoZuIAAaBHWJfOOJ5wZRAQ" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "root_key_untrusted" + ] + } +} diff --git a/examples/delegation-link/09-trusted-key-below-the-root.json b/examples/delegation-link/09-trusted-key-below-the-root.json new file mode 100644 index 0000000..571fb3e --- /dev/null +++ b/examples/delegation-link/09-trusted-key-below-the-root.json @@ -0,0 +1,160 @@ +{ + "id": "TRACE-DELEG-009", + "name": "trusted-key-below-the-root", + "description": "The root is the stranger again, but this time the *second* record on the chain is held by the trusted orchestrator key. Every hop is sound and the chain is still anchored to nobody: authority does not begin partway up. A verifier that anchors on the highest trusted key it finds, rather than on the record with no delegation block, reports this chain verified — and still fails vector 08, where no record carries a trusted key at all.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:f4ebc66afd1a0980c197ffe1d72ca4b9e7cae20755f0e7f14d05d4dde79cfdb7", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:stranger-to-orchestrator": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/orchestrator", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:0e1eff74077463368dff6cf940d9f8c0e37aa5af83f16abd193900d04a95cb3c", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "xOaYSbDnzUMGzZUCsAuRf1MlY6CHCR1FUXK5V7SW8eF0SJ1xwjFiAWzRLB7nGrA23cAXiZ7igGOcAWsNlF-lCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:ceda95720fbfdf66be21cc2f866dd8d4004b1d15873ec461ad343845769da570", + "credential_id": "cred:stranger-to-orchestrator" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "NG3IIR9d-DqxZofLCGb8OFYfsrvLoYGO8vVk7AIUrjTgSRVH4WCrklU5OCMlQtGK8CAHxh6TjtexksO3KmMiDQ" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "_TJMhmc0FvvJlaI_nDsLUmSOJrjBiKqbNj16qh___YMAoWtCsx1LZE6kbfd61qoDcoZuIAAaBHWJfOOJ5wZRAQ" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "root_key_untrusted" + ] + } +} diff --git a/examples/delegation-link/10-credential-not-registered.json b/examples/delegation-link/10-credential-not-registered.json new file mode 100644 index 0000000..8effae9 --- /dev/null +++ b/examples/delegation-link/10-credential-not-registered.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-010", + "name": "credential-not-registered", + "description": "The hop names a credential the verifier holds nothing for. The chain is structurally sound; the authority it claims cannot be looked up.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:6107269d774276ab0f19d62ba285a6c91c890fe2cea715472537a925496cbc00", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:never-issued" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "no3gXo5r5OUWIL-Wuqj5lenTvU0CXN4fVsCBOEmu3tQwCU9K9wg0NdI6vYEg6Zy0XzwrtIw2SynhceZ6cPXLAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_unknown" + ] + } +} diff --git a/examples/delegation-link/11-credential-id-case-differs.json b/examples/delegation-link/11-credential-id-case-differs.json new file mode 100644 index 0000000..8ad61a9 --- /dev/null +++ b/examples/delegation-link/11-credential-id-case-differs.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-011", + "name": "credential-id-case-differs", + "description": "The hop names `CRED:Orchestrator-To-Planner`, differing from the registered id only in case. `credential_id` is an opaque octet string with no case-folding rule, so this is an unknown credential — and a verifier that lowercases before lookup accepts an identifier nobody issued.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:642a127409daae86933967acfb5b398a66e924af57aa193f742789434b1f2a74", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "CRED:Orchestrator-To-Planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "y3xyLFI-1mYb7M0zfMDJBrknk-kGwsUMy_09pvYm7ihNZVa3FBY1cOjgNPvMU4l6w0i7ZlVxcmCX9EDjnOHdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_unknown" + ] + } +} diff --git a/examples/delegation-link/12-credential-issued-by-third-party.json b/examples/delegation-link/12-credential-issued-by-third-party.json new file mode 100644 index 0000000..dbc9036 --- /dev/null +++ b/examples/delegation-link/12-credential-issued-by-third-party.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-012", + "name": "credential-issued-by-third-party", + "description": "A registered, in-window credential naming the courier as issuer, used on a hop whose parent is the orchestrator. Authority that did not come from the delegating hop is not delegation.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_issuer_mismatch" + ] + } +} diff --git a/examples/delegation-link/13-credential-self-issued.json b/examples/delegation-link/13-credential-self-issued.json new file mode 100644 index 0000000..8703b0c --- /dev/null +++ b/examples/delegation-link/13-credential-self-issued.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-013", + "name": "credential-self-issued", + "description": "The credential names the *holder* as its own issuer. A verifier comparing the issuer against the record under appraisal rather than against its parent reads this as consistent, and grants an agent whatever it wrote down for itself.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_issuer_mismatch" + ] + } +} diff --git a/examples/delegation-link/14-credential-held-by-third-party.json b/examples/delegation-link/14-credential-held-by-third-party.json new file mode 100644 index 0000000..fb71b04 --- /dev/null +++ b/examples/delegation-link/14-credential-held-by-third-party.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-014", + "name": "credential-held-by-third-party", + "description": "The credential was issued by the right party to somebody else. Replaying a valid credential issued to a different agent is the attack a holder check exists for.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_holder_mismatch" + ] + } +} diff --git a/examples/delegation-link/15-credential-holder-is-the-parent.json b/examples/delegation-link/15-credential-holder-is-the-parent.json new file mode 100644 index 0000000..7c2756b --- /dev/null +++ b/examples/delegation-link/15-credential-holder-is-the-parent.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-015", + "name": "credential-holder-is-the-parent", + "description": "Issuer and holder both name the parent. A verifier that has the two comparisons the wrong way round finds the holder where it expects the issuer, reports agreement, and passes this while failing 14.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/orchestrator", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_holder_mismatch" + ] + } +} diff --git a/examples/delegation-link/16-credential-expired-at-hop.json b/examples/delegation-link/16-credential-expired-at-hop.json new file mode 100644 index 0000000..b655541 --- /dev/null +++ b/examples/delegation-link/16-credential-expired-at-hop.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-016", + "name": "credential-expired-at-hop", + "description": "The hop executed after its credential's `not_after`. Judged against the hop's own `iat`, not against the verifier's clock: a chain does not become invalid because it is being read late.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784992800, + "not_after": 1784996400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_window" + ] + } +} diff --git a/examples/delegation-link/17-credential-not-yet-valid-at-hop.json b/examples/delegation-link/17-credential-not-yet-valid-at-hop.json new file mode 100644 index 0000000..68c3287 --- /dev/null +++ b/examples/delegation-link/17-credential-not-yet-valid-at-hop.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-017", + "name": "credential-not-yet-valid-at-hop", + "description": "The hop executed before its credential's `not_before`. The other side of the window, and the side an implementation reaches for expiry alone leaves open — a credential dated into the future authorises everything done before it existed.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1785003600, + "not_after": 1785007200 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_window" + ] + } +} diff --git a/examples/delegation-link/18-data-class-widened-at-leaf.json b/examples/delegation-link/18-data-class-widened-at-leaf.json new file mode 100644 index 0000000..b64dc56 --- /dev/null +++ b/examples/delegation-link/18-data-class-widened-at-leaf.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-018", + "name": "data-class-widened-at-leaf", + "description": "A hop delegated from an `internal` parent declares `restricted`. Delegation cannot manufacture reach the delegator did not have.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:df9cf83aa91975271e107340efbf56c62f728d768acefaa9a0b4c4760f25fbad", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:577823d463ee91ace643bb1e3cde34094c3705dc1a8b349b77003b05a4eaf194", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "C1IhTRYCK34UCumyk13hA_41Xypdae3csiOkfT31G3-C9anB22bO_pEx3MlKYfhLKJeUldB3QMJfMTR0ykIRAA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "6p-6Zx3azYR7CZ7fNKWSPoUO4KnX5LTP0y5sE3P2jPMHDLcb96UDnnMSuN4XI7g8GEsLOXcgidZi6MOSZbVTDw" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "data_class_widened" + ] + } +} diff --git a/examples/delegation-link/19-data-class-widened-mid-chain.json b/examples/delegation-link/19-data-class-widened-mid-chain.json new file mode 100644 index 0000000..5251e62 --- /dev/null +++ b/examples/delegation-link/19-data-class-widened-mid-chain.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-019", + "name": "data-class-widened-mid-chain", + "description": "Widening at the first hop, inside a chain whose leaf is narrower than its root. Comparing the leaf against the root shows narrowing and reports nothing; the rule is per hop, and this is the vector that says so.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:0ac3e0a7eac8cc5a7f5c504b2259110094fd220a7042774fe99e058f74b74c74", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:cde925006e1ac25ca66c3b615c51ebc97c953ca10efae9c6e116baea1ebfcac4", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "85dbWZVZkFrGFqMtCBIEIzrFwCTL-yVLA2HxwcK_mM9MNaCxEbJUEhvqyMuhIkDI3LG3dS91ppsAB07uX1KTCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:5d7653249cc8e3ffcd994aa02c7068cd06198eace3689f265f663eccb705a826", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "zepjJsVAEZ5YC1yvwIlf6fGH-bSMeC2y1v4GjSv_0WlRpKQtD_xnbCURbRTAnNLCxFPVKpTHNNhJ3qs7EwhYAg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:20d2a1b91f90bedc4513b0d7518ea18d380d6ebbfd010dbcad496d536c73acf3", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "ifBTLAif-3ndswuVAFfiaoHE3Lj73fm3MWeleYMhHfzYNgwpG_F1jc3fxW5nbwpsQmBEPtxeq6-w3qhGKd_3Bg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "Xpe0aEdxpjj_mvm-TUUaai9JIvEViB_-BcXq3a-ox2EoTxdId3lswf79CVi681BX8siMO4pM6hF8RZp22wviAw" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "data_class_widened" + ] + } +} diff --git a/examples/delegation-link/20-depth-far-past-the-bound.json b/examples/delegation-link/20-depth-far-past-the-bound.json new file mode 100644 index 0000000..2313c13 --- /dev/null +++ b/examples/delegation-link/20-depth-far-past-the-bound.json @@ -0,0 +1,336 @@ +{ + "id": "TRACE-DELEG-020", + "name": "depth-far-past-the-bound", + "description": "Six delegations against a bound of four. An implementation whose bound is off by one — or absent, walking until the records run out — is separated from a correct one by 21, not by this; this one is what an unbounded walk looks like when nobody is counting at all.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:41e60139aea7d50dd42f0efc8cbdaea2978d915df9383ff8c49cdc33138c869b", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:auditor-to-stranger": { + "issuer": "spiffe://acme.example/agent/auditor", + "holder": "spiffe://acme.example/agent/stranger", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:stranger-to-planner-deep": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:059125d665345b413d60b30401d1453d6258e6779699e1e165f2d2217c20a0ed", + "credential_id": "cred:stranger-to-planner-deep" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "lJZPFInY6IWhd8RIm9v7kswTAnUFBCNf59hYLjj8-6-B7DbpmqeNqDD76IozSU27sDTB-zdizbhLe8NszayoDw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "credential_id": "cred:auditor-to-stranger" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "QNyWokvMGYuvzxhWQEXdwwSt_eGmKQ425NTIlHfVrYtODw0YV5IpGh0qEn9zXLQU_mMHaXn7v46z0JS11RWbDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "depth_exceeded" + ] + } +} diff --git a/examples/delegation-link/21-depth-one-past-the-bound.json b/examples/delegation-link/21-depth-one-past-the-bound.json new file mode 100644 index 0000000..782542b --- /dev/null +++ b/examples/delegation-link/21-depth-one-past-the-bound.json @@ -0,0 +1,292 @@ +{ + "id": "TRACE-DELEG-021", + "name": "depth-one-past-the-bound", + "description": "Five delegations against a bound of four. One past, so a verifier comparing with the wrong operator accepts it — and vector 02 sits exactly on the bound and must not be rejected, which pins the comparison from both sides.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:059125d665345b413d60b30401d1453d6258e6779699e1e165f2d2217c20a0ed", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:auditor-to-stranger": { + "issuer": "spiffe://acme.example/agent/auditor", + "holder": "spiffe://acme.example/agent/stranger", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "credential_id": "cred:auditor-to-stranger" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "QNyWokvMGYuvzxhWQEXdwwSt_eGmKQ425NTIlHfVrYtODw0YV5IpGh0qEn9zXLQU_mMHaXn7v46z0JS11RWbDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "depth_exceeded" + ] + } +} diff --git a/examples/delegation-link/22-leaf-link-uses-sha384.json b/examples/delegation-link/22-leaf-link-uses-sha384.json new file mode 100644 index 0000000..4b70904 --- /dev/null +++ b/examples/delegation-link/22-leaf-link-uses-sha384.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-022", + "name": "leaf-link-uses-sha384", + "description": "The leaf links to its parent by a sha384 digest, which the schema permits and this verifier does not implement. The digest is correct: a verifier that supports sha384 resolves the link and verifies the chain. The outcome is unverifiable, not invalid — nothing here contradicts, it just cannot be read.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:9480306c822d8db40120621b12b03a6e6bbbcede73c6e4bd99e8cef6c880a6c0", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha384:f1a0e81fba0858571d2f85a593040d83ff1ddfdfd5c19c8318783f46f25170402c695c07dbef9507883edf76d7c8f893", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "85M197lOYQ8oWZkkK3aUupPPq9xNwFgQX6WgzzC2EoxCfveD_uZ3R9tvO6_0XF0pY_bAuqQZ1TFyCYrQEOk6Bg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "unverifiable", + "codes": [ + "digest_algorithm_unsupported" + ] + } +} diff --git a/examples/delegation-link/23-deep-link-uses-sha384.json b/examples/delegation-link/23-deep-link-uses-sha384.json new file mode 100644 index 0000000..1d7a28d --- /dev/null +++ b/examples/delegation-link/23-deep-link-uses-sha384.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-023", + "name": "deep-link-uses-sha384", + "description": "The same unreadable link two hops up, with a sha256 link at the leaf. A verifier that inspects the algorithm of the first link and assumes the rest of the chain matches reports this chain verified, having never resolved half of it.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1560b386744ac9f59b1ccab93a3875ec34fc4727c690f7737e77793fbd015615", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:981b8eabe4b44f980f473b75ab879066de27e0219d246eed25cd9683e13b9da8", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "v1KCZwrQJJxdEZL38sh8kLDf-xNHtbDa1R6KhQgZ0UcAlGVKzIlFwmvhmhVp2jjU9l0hMQrjY63nf_jfLnJcAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:f108528ee46015ea711f2af8d13e1d43acb7b5589f1386d6fd3f3397cb40d944", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "cn-Al-dw5JPf4b3q0IExv9nG1ZuoXJBjuZ--2d7zwzD0S2Jjza4OApCJEIwtug1KGPBg-Ty1LQJSMWfE8uMxBg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha384:f1a0e81fba0858571d2f85a593040d83ff1ddfdfd5c19c8318783f46f25170402c695c07dbef9507883edf76d7c8f893", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "dDyHQ26TDFbEwRo_7tbCWTHr3MWB-PJRZaiaKeRDu0Ju-LCGvG5fFNW1RWKBF7WmTEcYiRfiyLLHYIOvyMdTAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "unverifiable", + "codes": [ + "digest_algorithm_unsupported" + ] + } +} diff --git a/examples/delegation-link/README.md b/examples/delegation-link/README.md new file mode 100644 index 0000000..c9f72d0 --- /dev/null +++ b/examples/delegation-link/README.md @@ -0,0 +1,77 @@ +# Delegation-link conformance vectors + +Conformance material for the profile proposed in +[`docs/rfcs/a2a-delegation-profile.md`](../../docs/rfcs/a2a-delegation-profile.md). +The `delegation` block is normative in v0.2; the rules for verifying a chain of them are +not, and these vectors exist so the proposal can be argued against executable material. + +Nothing here binds an implementation. It scores one. + +## Running them + +``` +python -m pytest tests/test_delegation_vectors.py tests/test_delegation_completeness.py +``` + +To score an implementation that is not this one, read the vectors directly — each is +self-contained and needs nothing from this repository. + +## What a vector is + +One JSON file, one scenario: + +| Field | | +|---|---| +| `id` | `TRACE-DELEG-NNN`, stable, never reused | +| `context.leaf` | digest of the record under appraisal | +| `context.trusted_root_keys` | JWKs the verifier anchors on | +| `context.credentials` | the delegation credential registry, held out of band | +| `context.data_class_lattice` | least sensitive first; `data_class` is an open string in the schema, so the ordering has to be supplied | +| `context.max_depth` | links the verifier will follow | +| `context.supported_digest_algorithms` | what this verifier can compute | +| `records` | the record set, **emitted leaf-first** | +| `expected` | classification and codes | + +`records` is a set, not a sequence. It is emitted leaf-first precisely so that an +implementation reading `records[0]` as the root fails immediately rather than passing until +its first shuffled input. `tests/test_delegation_vectors.py` re-runs every vector under two +further permutations. + +Every record validates against `schema/trace-claim.json`, including the ones built to fail. + +## Classifications + +Three outcomes, and collapsing any two of them is the nonconformance the split exists to +name: + +- `provenance-invalid` — the chain's structure or signatures are broken +- `authorization-invalid` — the chain is sound and the authority it claims is not +- `unverifiable` — a link this verifier could not read; not a finding against the chain +- `verified` + +## Reproducing them + +``` +python examples/delegation-link/gen_delegation_vectors.py +``` + +Keys derive from one published seed by role label — no secret, fully reissuable by anyone. +`tests/test_generators_reproduce_fixtures.py` regenerates the set into an emptied directory +and compares bytes on every run, with no entry in its `NOT_GENERATED` ledger. + +## Coverage + +Ten rules, two load-bearing vectors each, and for every rule at least one declared +implementation defect that one of its vectors catches and the other misses. +`tests/test_delegation_completeness.py` enforces all three, and records the margins so they +cannot silently thin. + +The pairs are not two views of the same mistake. They are built around a specific shortcut a +real implementation takes — verifying the leaf only, anchoring on any trusted key it finds, +an off-by-one bound, case-insensitive lookup of an opaque identifier, issuer and holder +compared to the wrong ends of the hop, half a validity window, narrowing checked at one hop, +the link algorithm read once and assumed uniform. + +Vector 05 is the one to read first. It is a complete, correctly signed chain whose only +defect is which bytes its link was computed over, and it is the whole of the difference +between two readings of one sentence in `docs/schema.md`. diff --git a/examples/delegation-link/gen_delegation_vectors.py b/examples/delegation-link/gen_delegation_vectors.py new file mode 100644 index 0000000..820201d --- /dev/null +++ b/examples/delegation-link/gen_delegation_vectors.py @@ -0,0 +1,637 @@ +"""Generate delegation-link conformance vectors for the proposed A2A profile. + +The `delegation` block is normative in v0.2: `schema/trace-claim.json` requires +`parent_record_hash` and `credential_id`, and pins the first to a sha256/sha384 +digest string. What a verifier is supposed to *do* with a chain of them is not +normative anywhere. `spec/trace-v0.2.md` never mentions either field; the only +prose is `docs/schema.md`, which says a verifier "walks `parent_record_hash` from +a leaf record back to the root and confirms each hop acted under a credential in +the delegation chain" — one sentence, no rules. `ROADMAP.md` targets the normative +A2A profile at v0.3. + +These vectors encode the rules proposed in `docs/rfcs/a2a-delegation-profile.md`, +so that the proposal can be argued against executable material rather than in the +abstract. Each vector is a complete record set plus the verifier context it must +be judged under, and carries its own expected classification and codes. Nothing +here is normative until the proposal is. + +Three things were settled while building this set, each because a vector could not +be written without settling it. They are the reason the corpus exists at this +stage rather than after the profile is written: + +**The digest preimage.** "Digest of the parent hop's Trust Record" does not say +which bytes. Over the RFC 8785 encoding of the complete record, signature +included, or over the signed body only? The two readings are both natural and +they are not interoperable — a chain built under one is a chain of dangling links +under the other. The profile takes the complete record, because under the other +reading a child's commitment does not bind the parent's *signer*: anyone may +re-sign identical body bytes under a different key and produce a record the child +still points at. Vector 05 is that difference and nothing else. + +**Cycles cannot be built.** A cycle would need A's block to carry a digest of B +while B's carries a digest of A, and each digest covers the block holding the +other, so constructing one is a hash collision. The profile therefore has no +cycle rule and says why; what it does need is a depth bound, because an +*unbounded* chain is constructible and a walk without a limit is a denial of +service. That is `depth_exceeded`, and it is the only reason the bound exists. + +**Only the leaf's signature is independently attackable.** Every ancestor's bytes +are already committed to by its child, so a record cannot be altered in place +without breaking the link that names it. An ancestor with an invalid signature is +still reachable — it just has to be built that way from the start rather than +tampered with afterwards — which is why `sign_key` is a build-time parameter here +and no vector is produced by mutating a finished chain. + +Keys derive from one published seed, per role, so the whole set regenerates +byte-for-byte and a third party can reissue any of it. Public test material with +no secret in it. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +OUT = Path("examples/delegation-link") +V0_2 = "tag:agentrust-io.com,2026:trace-v0.2" +PROFILE = "trace.a2a.delegation-link.v0" +SPEC = "docs/rfcs/a2a-delegation-profile.md" + +#: One published seed; every role key is a labelled derivation from it, so the +#: corpus needs a single secret-free constant to be fully reissuable. The label is +#: part of the preimage rather than a counter, so adding a role later cannot shift +#: an existing role's key. +SEED = hashlib.sha256(b"trace-spec delegation-link fixture key").digest() + +ROLES = ("orchestrator", "planner", "executor", "courier", "auditor", "stranger") + +#: Least sensitive first. `data_class` is an open string in the schema, so an +#: ordering cannot be inferred from a record; it is verifier context, supplied +#: per vector exactly like the trusted key set. +LATTICE = ("public", "internal", "confidential", "restricted") + +#: Delegation hops beyond the root that a verifier will follow. Vector 02 sits +#: exactly on it and must verify; 21 sits one past it. +MAX_DEPTH = 4 + +#: Fixed, so every fixture regenerates identically. Vectors that turn on time +#: move the credential window, never the clock. +NOW = 1785000000 + + +def key_for(role: str) -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(SEED + b"|" + role.encode()).digest() + ) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def jwk_for(role: str) -> dict[str, str]: + raw = key_for(role).public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + return {"kty": "OKP", "crv": "Ed25519", "x": b64u(raw)} + + +def subject_for(role: str) -> str: + return f"spiffe://acme.example/agent/{role}" + + +def base_record(role: str, *, iat: int, data_class: str) -> dict[str, Any]: + """A schema-valid v0.2 record for `role`, with no delegation block yet. + + Deliberately minimal: every field here is required by the schema, and nothing + optional is present except what a vector adds. A record that carries more than + the rules under test can read is a record whose failures are harder to place. + """ + return { + "eat_profile": V0_2, + "iat": iat, + "subject": subject_for(role), + "model": {"provider": "anthropic", "model_id": "claude-sonnet-4-6"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "00" * 32}, + "policy": { + "bundle_hash": "sha256:" + "aa" * 32, + "enforcement_mode": "enforce", + }, + "data_class": data_class, + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + "bb" * 32}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example/v1"}, + } + + +def signed(body: dict[str, Any], *, sign_key: str) -> dict[str, Any]: + """Seal `body` under `sign_key`'s private half, declaring `cnf.jwk` separately. + + `cnf.jwk` is set by the caller before this runs when a vector needs the record + to *claim* one key and be signed by another; when it is absent the claimed key + is the signing key, which is the honest case. + """ + record = dict(body) + record.setdefault("cnf", {"jwk": jwk_for(sign_key)}) + record["signature"] = b64u(key_for(sign_key).sign(rfc8785.dumps(record))) + return record + + +def digest(record: dict[str, Any], alg: str = "sha256") -> str: + """The profile's preimage: RFC 8785 bytes of the complete record.""" + return f"{alg}:" + hashlib.new(alg, rfc8785.dumps(record)).hexdigest() + + +def body_digest(record: dict[str, Any], alg: str = "sha256") -> str: + """The rejected reading: the same record with `signature` removed. + + Present only so vector 05 can be built. Nothing in the profile computes this. + """ + body = {k: v for k, v in record.items() if k != "signature"} + return f"{alg}:" + hashlib.new(alg, rfc8785.dumps(body)).hexdigest() + + +def credential( + cid: str, + *, + issuer: str, + holder: str, + not_before: int = NOW - 86400, + not_after: int = NOW + 86400, +) -> tuple[str, dict[str, Any]]: + return cid, { + "issuer": subject_for(issuer), + "holder": subject_for(holder), + "not_before": not_before, + "not_after": not_after, + } + + +def build_chain(hops: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build a chain root-first, returning the records in root-to-leaf order. + + `hops[0]` is the root and takes no delegation block. Each later hop reads: + + role, data_class, iat — the record's own content + credential_id — what its delegation block names + parent_alg — digest algorithm for the link (default sha256) + sign_key — signer, when it must differ from `role` + claimed_key — what `cnf.jwk` advertises, when it must differ + from the signer + link_over_body — link to the parent's signed body rather than + the complete record (vector 05 only) + + Built rather than mutated: a finished chain cannot be edited anywhere but the + leaf without breaking the link that commits to the edited record, so every + defect involving an ancestor has to exist before its child is signed. + """ + records: list[dict[str, Any]] = [] + for index, hop in enumerate(hops): + body = base_record( + hop["role"], iat=hop.get("iat", NOW), data_class=hop["data_class"] + ) + if index: + parent = records[-1] + alg = hop.get("parent_alg", "sha256") + link = body_digest if hop.get("link_over_body") else digest + body["delegation"] = { + "parent_record_hash": link(parent, alg), + "credential_id": hop["credential_id"], + } + if "claimed_key" in hop: + body["cnf"] = {"jwk": jwk_for(hop["claimed_key"])} + records.append(signed(body, sign_key=hop.get("sign_key", hop["role"]))) + return records + + +def vector( + vid: str, + name: str, + description: str, + records: list[dict[str, Any]], + *, + classification: str, + codes: list[str], + credentials: dict[str, Any], + trusted_roots: tuple[str, ...] = ("orchestrator",), + supported_algorithms: tuple[str, ...] = ("sha256",), + leaf: dict[str, Any] | None = None, + emit: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """One scenario, with everything a third party needs to judge it offline. + + `records` is emitted last-to-first. A verifier that assumes the set arrives in + chain order, or that treats `records[0]` as the root, fails every vector here + rather than passing until the first shuffled input reaches production. + """ + under_appraisal = leaf if leaf is not None else records[-1] + emitted = emit if emit is not None else records + return { + "id": vid, + "name": name, + "description": description, + "spec": SPEC, + "profile": PROFILE, + "context": { + "leaf": digest(under_appraisal), + "now": NOW, + "max_depth": MAX_DEPTH, + "supported_digest_algorithms": supported_algorithms, + "data_class_lattice": list(LATTICE), + "trusted_root_keys": [jwk_for(role) for role in trusted_roots], + "credentials": credentials, + }, + "records": list(reversed(emitted)), + "expected": {"classification": classification, "codes": codes}, + } + + +# --- the credential registry the well-formed chains draw on -------------------- + +CRED_PLANNER = credential("cred:orchestrator-to-planner", issuer="orchestrator", holder="planner") +CRED_EXECUTOR = credential("cred:planner-to-executor", issuer="planner", holder="executor") +CRED_COURIER = credential("cred:executor-to-courier", issuer="executor", holder="courier") +CRED_AUDITOR = credential("cred:courier-to-auditor", issuer="courier", holder="auditor") + +WELL_FORMED = dict([CRED_PLANNER, CRED_EXECUTOR, CRED_COURIER, CRED_AUDITOR]) + +#: Root to leaf, narrowing at every hop, exactly `MAX_DEPTH` delegations deep. +FULL_DEPTH_HOPS = [ + {"role": "orchestrator", "data_class": "restricted"}, + {"role": "planner", "data_class": "restricted", "credential_id": CRED_PLANNER[0]}, + {"role": "executor", "data_class": "confidential", "credential_id": CRED_EXECUTOR[0]}, + {"role": "courier", "data_class": "internal", "credential_id": CRED_COURIER[0]}, + {"role": "auditor", "data_class": "public", "credential_id": CRED_AUDITOR[0]}, +] + +SINGLE_HOP = [ + {"role": "orchestrator", "data_class": "restricted"}, + {"role": "planner", "data_class": "confidential", "credential_id": CRED_PLANNER[0]}, +] + + +def main() -> None: + out: list[tuple[str, dict[str, Any]]] = [] + + def add(filename: str, doc: dict[str, Any]) -> None: + out.append((filename, doc)) + + # -- verified --------------------------------------------------------------- + + add("01-valid-single-hop.json", vector( + "TRACE-DELEG-001", "valid-single-hop", + "One delegation from a trusted root. The shortest chain the profile has " + "anything to say about, and the case every rule below is a departure from.", + build_chain(SINGLE_HOP), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + add("02-valid-full-depth-out-of-order.json", vector( + "TRACE-DELEG-002", "valid-full-depth-out-of-order", + "Four delegations, exactly on `max_depth`, narrowing at every hop, with " + "the record set emitted leaf-first. Verifies. A verifier whose bound is " + "off by one rejects this, which is the other half of vector 21.", + build_chain(FULL_DEPTH_HOPS), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + add("03-valid-root-only.json", vector( + "TRACE-DELEG-003", "valid-root-only", + "A record with no delegation block at all. Not a degenerate chain: a root " + "execution is the ordinary case, and a verifier that requires the block to " + "be present has broken every non-delegated record.", + build_chain([FULL_DEPTH_HOPS[0]]), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + # -- parent_not_found ------------------------------------------------------- + + full = build_chain(FULL_DEPTH_HOPS) + add("04-parent-record-absent.json", vector( + "TRACE-DELEG-004", "parent-record-absent", + "The chain is sound, and the record the third hop names is simply not in " + "the set. Presenting a leaf without the hops that authorise it is the " + "cheapest attack on a chain nobody walks to the end.", + full, + classification="provenance-invalid", codes=["parent_not_found"], + credentials=WELL_FORMED, + emit=[r for i, r in enumerate(full) if i != 2], + )) + + add("05-link-over-signed-body.json", vector( + "TRACE-DELEG-005", "link-over-signed-body", + "Every record is present and correctly signed; the leaf's " + "`parent_record_hash` is a digest of its parent's signed body with " + "`signature` removed, rather than of the complete record. This is the one " + "vector that separates the two readings of \"digest of the parent hop's " + "Trust Record\": under the profile's reading the link resolves to nothing, " + "under the rejected reading the whole chain verifies.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "link_over_body": True}, + ]), + classification="provenance-invalid", codes=["parent_not_found"], + credentials=WELL_FORMED, + )) + + # -- record_signature_invalid ----------------------------------------------- + + add("06-leaf-signed-by-other-key.json", vector( + "TRACE-DELEG-006", "leaf-signed-by-other-key", + "The leaf advertises the executor's key in `cnf.jwk` and is signed by the " + "stranger's. The leaf is the only record in a chain whose bytes nothing " + "else commits to, so it is the only one an attacker can reach in place.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "sign_key": "stranger", "claimed_key": "planner"}, + ]), + classification="provenance-invalid", codes=["record_signature_invalid"], + credentials=WELL_FORMED, + )) + + add("07-intermediate-signed-by-other-key.json", vector( + "TRACE-DELEG-007", "intermediate-signed-by-other-key", + "The same defect one hop up, built in before the child committed to it, so " + "the link still resolves and only the signature is wrong. A verifier that " + "checks the leaf and trusts the rest of the chain because 'the hashes " + "match' passes this and fails vector 06.", + build_chain([ + FULL_DEPTH_HOPS[0], + {**FULL_DEPTH_HOPS[1], "sign_key": "stranger", "claimed_key": "planner"}, + FULL_DEPTH_HOPS[2], + ]), + classification="provenance-invalid", codes=["record_signature_invalid"], + credentials=WELL_FORMED, + )) + + # -- root_key_untrusted ----------------------------------------------------- + + add("08-root-key-untrusted.json", vector( + "TRACE-DELEG-008", "root-key-untrusted", + "A correctly signed, internally consistent single-hop chain whose root is " + "held by a key the verifier was never given. Every hash matches and every " + "signature verifies; the chain is anchored to nothing.", + build_chain([ + {"role": "stranger", "data_class": "restricted"}, + {"role": "planner", "data_class": "confidential", + "credential_id": "cred:stranger-to-planner"}, + ]), + classification="provenance-invalid", codes=["root_key_untrusted"], + credentials=dict([ + credential("cred:stranger-to-planner", issuer="stranger", holder="planner"), + ]), + )) + + add("09-trusted-key-below-the-root.json", vector( + "TRACE-DELEG-009", "trusted-key-below-the-root", + "The root is the stranger again, but this time the *second* record on the " + "chain is held by the trusted orchestrator key. Every hop is sound and the " + "chain is still anchored to nobody: authority does not begin partway up. A " + "verifier that anchors on the highest trusted key it finds, rather than on " + "the record with no delegation block, reports this chain verified — and " + "still fails vector 08, where no record carries a trusted key at all.", + build_chain([ + {"role": "stranger", "data_class": "restricted"}, + {"role": "orchestrator", "data_class": "restricted", + "credential_id": "cred:stranger-to-orchestrator"}, + {"role": "planner", "data_class": "confidential", + "credential_id": CRED_PLANNER[0]}, + ]), + classification="provenance-invalid", codes=["root_key_untrusted"], + credentials=dict([ + credential("cred:stranger-to-orchestrator", issuer="stranger", + holder="orchestrator"), + CRED_PLANNER, + ]), + )) + + # -- credential_unknown ----------------------------------------------------- + + add("10-credential-not-registered.json", vector( + "TRACE-DELEG-010", "credential-not-registered", + "The hop names a credential the verifier holds nothing for. The chain is " + "structurally sound; the authority it claims cannot be looked up.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "credential_id": "cred:never-issued"}, + ]), + classification="authorization-invalid", codes=["credential_unknown"], + credentials=WELL_FORMED, + )) + + add("11-credential-id-case-differs.json", vector( + "TRACE-DELEG-011", "credential-id-case-differs", + "The hop names `CRED:Orchestrator-To-Planner`, differing from the " + "registered id only in case. `credential_id` is an opaque octet string " + "with no case-folding rule, so this is an unknown credential — and a " + "verifier that lowercases before lookup accepts an identifier nobody " + "issued.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "credential_id": "CRED:Orchestrator-To-Planner"}, + ]), + classification="authorization-invalid", codes=["credential_unknown"], + credentials=WELL_FORMED, + )) + + # -- credential_issuer_mismatch --------------------------------------------- + + add("12-credential-issued-by-third-party.json", vector( + "TRACE-DELEG-012", "credential-issued-by-third-party", + "A registered, in-window credential naming the courier as issuer, used on " + "a hop whose parent is the orchestrator. Authority that did not come from " + "the delegating hop is not delegation.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_issuer_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="courier", holder="planner"), + ]), + )) + + add("13-credential-self-issued.json", vector( + "TRACE-DELEG-013", "credential-self-issued", + "The credential names the *holder* as its own issuer. A verifier " + "comparing the issuer against the record under appraisal rather than " + "against its parent reads this as consistent, and grants an agent whatever " + "it wrote down for itself.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_issuer_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="planner", holder="planner"), + ]), + )) + + # -- credential_holder_mismatch --------------------------------------------- + + add("14-credential-held-by-third-party.json", vector( + "TRACE-DELEG-014", "credential-held-by-third-party", + "The credential was issued by the right party to somebody else. Replaying " + "a valid credential issued to a different agent is the attack a holder " + "check exists for.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_holder_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", holder="courier"), + ]), + )) + + add("15-credential-holder-is-the-parent.json", vector( + "TRACE-DELEG-015", "credential-holder-is-the-parent", + "Issuer and holder both name the parent. A verifier that has the two " + "comparisons the wrong way round finds the holder where it expects the " + "issuer, reports agreement, and passes this while failing 14.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_holder_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="orchestrator"), + ]), + )) + + # -- credential_window ------------------------------------------------------ + + add("16-credential-expired-at-hop.json", vector( + "TRACE-DELEG-016", "credential-expired-at-hop", + "The hop executed after its credential's `not_after`. Judged against the " + "hop's own `iat`, not against the verifier's clock: a chain does not " + "become invalid because it is being read late.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_window"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="planner", not_before=NOW - 7200, not_after=NOW - 3600), + ]), + )) + + add("17-credential-not-yet-valid-at-hop.json", vector( + "TRACE-DELEG-017", "credential-not-yet-valid-at-hop", + "The hop executed before its credential's `not_before`. The other side of " + "the window, and the side an implementation reaches for expiry alone " + "leaves open — a credential dated into the future authorises everything " + "done before it existed.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_window"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="planner", not_before=NOW + 3600, not_after=NOW + 7200), + ]), + )) + + # -- data_class_widened ----------------------------------------------------- + + add("18-data-class-widened-at-leaf.json", vector( + "TRACE-DELEG-018", "data-class-widened-at-leaf", + "A hop delegated from an `internal` parent declares `restricted`. " + "Delegation cannot manufacture reach the delegator did not have.", + build_chain([ + {"role": "orchestrator", "data_class": "internal"}, + {"role": "planner", "data_class": "restricted", + "credential_id": CRED_PLANNER[0]}, + ]), + classification="authorization-invalid", codes=["data_class_widened"], + credentials=WELL_FORMED, + )) + + add("19-data-class-widened-mid-chain.json", vector( + "TRACE-DELEG-019", "data-class-widened-mid-chain", + "Widening at the first hop, inside a chain whose leaf is narrower than its " + "root. Comparing the leaf against the root shows narrowing and reports " + "nothing; the rule is per hop, and this is the vector that says so.", + build_chain([ + {"role": "orchestrator", "data_class": "confidential"}, + {"role": "planner", "data_class": "restricted", + "credential_id": CRED_PLANNER[0]}, + {"role": "executor", "data_class": "internal", + "credential_id": CRED_EXECUTOR[0]}, + {"role": "courier", "data_class": "public", + "credential_id": CRED_COURIER[0]}, + ]), + classification="authorization-invalid", codes=["data_class_widened"], + credentials=WELL_FORMED, + )) + + # -- depth_exceeded --------------------------------------------------------- + + deep_hops = FULL_DEPTH_HOPS + [ + {"role": "stranger", "data_class": "public", + "credential_id": "cred:auditor-to-stranger"}, + ] + add("21-depth-one-past-the-bound.json", vector( + "TRACE-DELEG-021", "depth-one-past-the-bound", + "Five delegations against a bound of four. One past, so a verifier " + "comparing with the wrong operator accepts it — and vector 02 sits exactly " + "on the bound and must not be rejected, which pins the comparison from " + "both sides.", + build_chain(deep_hops), + classification="authorization-invalid", codes=["depth_exceeded"], + credentials=dict(list(WELL_FORMED.items()) + [ + credential("cred:auditor-to-stranger", issuer="auditor", holder="stranger"), + ]), + )) + + deeper_hops = deep_hops + [ + {"role": "planner", "data_class": "public", + "credential_id": "cred:stranger-to-planner-deep"}, + ] + add("20-depth-far-past-the-bound.json", vector( + "TRACE-DELEG-020", "depth-far-past-the-bound", + "Six delegations against a bound of four. An implementation whose bound is " + "off by one — or absent, walking until the records run out — is separated " + "from a correct one by 21, not by this; this one is what an unbounded walk " + "looks like when nobody is counting at all.", + build_chain(deeper_hops), + classification="authorization-invalid", codes=["depth_exceeded"], + credentials=dict(list(WELL_FORMED.items()) + [ + credential("cred:auditor-to-stranger", issuer="auditor", holder="stranger"), + credential("cred:stranger-to-planner-deep", issuer="stranger", holder="planner"), + ]), + )) + + # -- digest_algorithm_unsupported ------------------------------------------- + + add("22-leaf-link-uses-sha384.json", vector( + "TRACE-DELEG-022", "leaf-link-uses-sha384", + "The leaf links to its parent by a sha384 digest, which the schema permits " + "and this verifier does not implement. The digest is correct: a verifier " + "that supports sha384 resolves the link and verifies the chain. The " + "outcome is unverifiable, not invalid — nothing here contradicts, it just " + "cannot be read.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "parent_alg": "sha384"}, + ]), + classification="unverifiable", codes=["digest_algorithm_unsupported"], + credentials=WELL_FORMED, + )) + + add("23-deep-link-uses-sha384.json", vector( + "TRACE-DELEG-023", "deep-link-uses-sha384", + "The same unreadable link two hops up, with a sha256 link at the leaf. A " + "verifier that inspects the algorithm of the first link and assumes the " + "rest of the chain matches reports this chain verified, having never " + "resolved half of it.", + build_chain([ + FULL_DEPTH_HOPS[0], + {**FULL_DEPTH_HOPS[1], "parent_alg": "sha384"}, + FULL_DEPTH_HOPS[2], + FULL_DEPTH_HOPS[3], + ]), + classification="unverifiable", codes=["digest_algorithm_unsupported"], + credentials=WELL_FORMED, + )) + + for name, doc in sorted(out): + (OUT / name).write_text( + json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/tests/delegation_margins.json b/tests/delegation_margins.json new file mode 100644 index 0000000..f1ed3fb --- /dev/null +++ b/tests/delegation_margins.json @@ -0,0 +1,12 @@ +{ + "credential_holder_mismatch": 2, + "credential_issuer_mismatch": 2, + "credential_unknown": 2, + "credential_window": 2, + "data_class_widened": 2, + "depth_exceeded": 2, + "digest_algorithm_unsupported": 2, + "parent_not_found": 2, + "record_signature_invalid": 2, + "root_key_untrusted": 2 +} diff --git a/tests/test_delegation_completeness.py b/tests/test_delegation_completeness.py new file mode 100644 index 0000000..159b123 --- /dev/null +++ b/tests/test_delegation_completeness.py @@ -0,0 +1,403 @@ +"""Completeness checks over the delegation-link vectors. + +`test_delegation_vectors.py` asks whether the vectors are *correct*. This module +asks whether they are *complete*, by the same five questions +`test_vector_completeness.py` asks of the action-receipt corpus, against the same +floor: two load-bearing vectors per rule, and at least one declared implementation +defect that tells the two apart. + +The method is unchanged and the reason is unchanged. Mutation targets named rule +hooks — a rule is deleted by rebuilding the registry without its entry, or weakened +by substituting its check — never by pattern-matching source text, so the mutation +cannot drift away from the code under test. `DEFECTS` is fail-closed: registering a +rule without declaring what its second vector adds fails this suite, so the +question "what bug would your second vector catch that your first would not?" is +answered when the rule is written rather than after a regression demonstrates it. + +Two of the defects declared below found real faults in the walk while this file was +being written, which is the argument for declaring them at all rather than +asserting margin and stopping: + + `anchor_on_any_trusted_key` is why vector 09 exists in its present form. The + first version put an untrusted root three hops down and no declared defect could + separate it from vector 08 — both were simply "the root is not trusted", twice. + The vector that separates them had to place a *trusted* key partway up the + chain, which is the shortcut an implementation actually takes. + + `off_by_one` on the depth bound could not deviate anything while the walk + repeated the depth comparison in its own break condition. A weakened bound never + got to walk further than a correct one, so both depth vectors moved together + under every mutation. The walk now breaks for one reason — an unresolvable + parent — and terminates on a visited set instead. +""" + +from __future__ import annotations + +import ast +import hashlib +import json +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path + +import pytest +import rfc8785 + +from tests.test_delegation_vectors import ( + CLASSIFICATIONS, + VECTOR_PATHS, + Hop, + Rule, + RULES, + _jwk_key, + _signature_invalid, + verify_chain, +) + +TESTS_DIR = Path(__file__).parent +WALK_MODULE = TESTS_DIR / "test_delegation_vectors.py" +MARGINS_FILE = TESTS_DIR / "delegation_margins.json" + +RULE_CODES = tuple(rule.code for rule in RULES) +VECTORS = [json.loads(path.read_text(encoding="utf-8")) for path in VECTOR_PATHS] +NAMES = [path.stem for path in VECTOR_PATHS] + + +# --------------------------------------------------------------------------- +# Declared defects: one weakened check per rule, minimum +# --------------------------------------------------------------------------- + +Check = Callable[[Hop], bool] + + +def _leaf_only_signature(hop: Hop) -> bool: + """Verify the leaf and take the rest of the chain on the strength of the hashes. + + Tempting because it is nearly right: a parent's bytes really are committed to + by its child, so nothing upstream can be altered in place. What the hashes do + not establish is that an ancestor was ever validly signed at all. + """ + return hop.depth == 0 and _signature_invalid(hop.record) + + +def _anchor_on_any_trusted_key(hop: Hop) -> bool: + """Accept the chain if any record on it carries a trusted key. + + The shape of "we found our own key in there somewhere, so this is ours". + """ + trusted = {_jwk_key(jwk) for jwk in hop.context["trusted_root_keys"]} + present = {_jwk_key(r.get("cnf", {}).get("jwk", {})) for r in hop.index.values()} + return not (trusted & present) + + +def _off_by_one_depth(hop: Hop) -> bool: + """`>` where the bound wanted `>=`, or a counter started at one.""" + return hop.depth > hop.context["max_depth"] + 1 + + +def _first_link_algorithm_only(hop: Hop) -> bool: + """Read the algorithm off the leaf's link and assume the chain is uniform.""" + return hop.depth == 1 and hop.link_algorithm not in hop.context[ + "supported_digest_algorithms" + ] + + +def _resolves_over_signed_body(hop: Hop) -> bool: + """Index parents by the digest of their signed body as well as the record. + + The other reading of "digest of the parent hop's Trust Record" — and an + implementation being liberal about which one it accepts ends up accepting both. + """ + if hop.link_algorithm not in hop.context["supported_digest_algorithms"]: + return False + wanted = hop.delegation["parent_record_hash"] + if wanted in hop.index: + return False + algorithm = hop.link_algorithm + for record in hop.index.values(): + body = {k: v for k, v in record.items() if k != "signature"} + if f"{algorithm}:" + hashlib.new(algorithm, rfc8785.dumps(body)).hexdigest() == wanted: + return False + return True + + +def _case_insensitive_credential_lookup(hop: Hop) -> bool: + """The classic 'be liberal in what you accept', applied to an opaque identifier.""" + wanted = hop.delegation["credential_id"].lower() + return not any(cid.lower() == wanted for cid in hop.context["credentials"]) + + +def _issuer_compared_to_the_record_itself(hop: Hop) -> bool: + """The right comparison against the wrong end of the hop.""" + credential = hop.credential + if credential is None: + return False + return credential["issuer"] != hop.record["subject"] + + +def _holder_compared_to_the_parent(hop: Hop) -> bool: + """Issuer and holder read in the wrong order.""" + credential = hop.credential + if credential is None: + return False + assert hop.parent is not None + return credential["holder"] != hop.parent["subject"] + + +def _expiry_only(hop: Hop) -> bool: + """Half a validity window. The half everyone remembers.""" + credential = hop.credential + if credential is None: + return False + return hop.record["iat"] > credential["not_after"] + + +def _narrowing_checked_at_the_first_hop_only(hop: Hop) -> bool: + """Compare the leaf against its parent and call the chain narrowed.""" + if hop.depth != 1: + return False + assert hop.parent is not None + lattice: list[str] = hop.context["data_class_lattice"] + if hop.record["data_class"] not in lattice or hop.parent["data_class"] not in lattice: + return False + return lattice.index(hop.record["data_class"]) > lattice.index(hop.parent["data_class"]) + + +DEFECTS: dict[str, dict[str, Check]] = { + "record_signature_invalid": {"verifies_the_leaf_only": _leaf_only_signature}, + "root_key_untrusted": {"anchor_on_any_trusted_key": _anchor_on_any_trusted_key}, + "depth_exceeded": {"off_by_one": _off_by_one_depth}, + "digest_algorithm_unsupported": {"first_link_only": _first_link_algorithm_only}, + "parent_not_found": {"resolves_over_signed_body": _resolves_over_signed_body}, + "credential_unknown": {"case_insensitive_lookup": _case_insensitive_credential_lookup}, + "credential_issuer_mismatch": { + "issuer_compared_to_the_record_itself": _issuer_compared_to_the_record_itself + }, + "credential_holder_mismatch": { + "holder_compared_to_the_parent": _holder_compared_to_the_parent + }, + "credential_window": {"expiry_only": _expiry_only}, + "data_class_widened": {"first_hop_only": _narrowing_checked_at_the_first_hop_only}, +} + + +# --------------------------------------------------------------------------- +# Mutation machinery +# --------------------------------------------------------------------------- + + +def _outcomes(rules: tuple[Rule, ...]) -> list[tuple[str, str, tuple[str, ...]]]: + out = [] + for name, vector in zip(NAMES, VECTORS, strict=True): + result = verify_chain(vector, rules) + out.append((name, result.classification, tuple(result.codes))) + return out + + +DECLARED = [ + (name, vector["expected"]["classification"], tuple(sorted(vector["expected"]["codes"]))) + for name, vector in zip(NAMES, VECTORS, strict=True) +] + + +def _without(code: str) -> tuple[Rule, ...]: + return tuple(rule for rule in RULES if rule.code != code) + + +def _weakened(code: str, check: Check) -> tuple[Rule, ...]: + return tuple( + replace(rule, check=check) if rule.code == code else rule for rule in RULES + ) + + +def _deviating(rules: tuple[Rule, ...]) -> set[str]: + """Vector names whose outcome under `rules` departs from their declared block.""" + return { + was[0] for was, now in zip(DECLARED, _outcomes(rules), strict=True) if was != now + } + + +def _margin(code: str) -> set[str]: + return _deviating(_without(code)) + + +# --------------------------------------------------------------------------- +# 0. Guards on the instrument itself +# --------------------------------------------------------------------------- + + +def test_the_unmutated_registry_agrees_with_every_declaration() -> None: + """Without this, every mutation test below measures deviation from a baseline + that already deviates, and a registry that agrees with nothing passes them all.""" + assert _deviating(RULES) == set() + + +def test_registry_is_well_formed() -> None: + assert VECTORS, "no vectors found" + assert len(RULES) >= 10, "the registry lost entries" + assert len(set(RULE_CODES)) == len(RULE_CODES), "duplicate rule codes" + assert all(rule.severity in {"failure", "warning"} for rule in RULES) + assert all(rule.klass in {"provenance", "authorization", "unverifiable"} for rule in RULES) + assert all(rule.path in {"record", "root", "resolve", "link"} for rule in RULES) + + +def test_no_emission_outside_the_registry() -> None: + """Nothing in the walk may emit a code around the registry. + + The registry is the inventory *because* `_evaluate` is the only place a code is + emitted. This walks the module's source and fails on an append to a failure or + warning collection anywhere outside that function, or a literal code passed + into a result's `failures=` / `warnings=` argument. + """ + tree = ast.parse(WALK_MODULE.read_text(encoding="utf-8")) + + enclosing: dict[ast.AST, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + for child in ast.walk(node): + enclosing.setdefault(child, node.name) + + offenders: list[str] = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in {"failures", "warnings"} + and enclosing.get(node) != "_evaluate" + ): + offenders.append(f"{enclosing.get(node, '')}:{node.lineno} append") + if isinstance(node, ast.keyword) and node.arg in {"failures", "warnings"}: + if isinstance(node.value, ast.List): + for element in node.value.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + offenders.append( + f"{enclosing.get(node, '')}: literal " + f"{element.value!r} in {node.arg}=" + ) + + assert not offenders, ( + "codes emitted outside the registry, which would make the rule inventory " + f"incomplete without failing anything: {offenders}" + ) + + +def test_registry_codes_are_literals() -> None: + """A code built from a variable or an f-string is invisible to every reader and + to the cross-reference table in the RFC.""" + tree = ast.parse(WALK_MODULE.read_text(encoding="utf-8")) + constructions = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "Rule" + ] + assert len(constructions) == len(RULES), ( + "the registry is not built from literal Rule(...) constructions in this module" + ) + assert all( + node.args and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + for node in constructions + ), "a rule code is not a string literal" + + +# --------------------------------------------------------------------------- +# 1-2. Dead expectations, unexercised rules, unreached outcomes +# --------------------------------------------------------------------------- + + +def test_no_vector_expects_a_code_the_registry_cannot_emit() -> None: + declared = {code for vector in VECTORS for code in vector["expected"]["codes"]} + unknown = sorted(declared - set(RULE_CODES)) + assert not unknown, f"vectors expect codes no rule emits: {unknown}" + + +def test_every_registered_rule_is_exercised_by_some_vector() -> None: + declared = {code for vector in VECTORS for code in vector["expected"]["codes"]} + idle = sorted(set(RULE_CODES) - declared) + assert not idle, f"registered rules no vector exercises: {idle}" + + +def test_every_declared_outcome_is_reached_by_some_vector() -> None: + reached = {vector["expected"]["classification"] for vector in VECTORS} + assert reached == CLASSIFICATIONS, ( + f"classifications the walk can return that no vector produces: " + f"{sorted(CLASSIFICATIONS - reached)}" + ) + + +# --------------------------------------------------------------------------- +# 3-4. Margin and independence +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("code", RULE_CODES) +def test_each_rule_is_load_bearing_for_two_vectors(code: str) -> None: + """Deleting an obligation must change at least two vectors' outcomes. + + One is existence; two is margin. With a single load-bearing vector, any change + that weakens or retires that vector silently removes the rule's coverage. + """ + margin = _margin(code) + assert len(margin) >= 2, ( + f"removing rule {code!r} changed {len(margin)} vector outcome(s) " + f"({sorted(margin)}). Two independent vectors are required per rule." + ) + + +def test_every_rule_declares_a_defect() -> None: + missing = sorted(set(RULE_CODES) - set(DEFECTS)) + assert not missing, f"registered rules with no declared defect variant: {missing}" + stale = sorted(set(DEFECTS) - set(RULE_CODES)) + assert not stale, f"defects declared for rules that no longer exist: {stale}" + + +@pytest.mark.parametrize("code", RULE_CODES) +def test_vectors_for_each_rule_are_independent(code: str) -> None: + """#124's criterion, executed: some single defect separates the rule's vectors.""" + bearing = _margin(code) + if len(bearing) < 2: + pytest.fail(f"rule {code!r} lacks two load-bearing vectors; the margin test covers this") + + separations = {} + for name, weakened_check in DEFECTS[code].items(): + caught = _deviating(_weakened(code, weakened_check)) & bearing + if 0 < len(caught) < len(bearing): + separations[name] = sorted(caught) + + assert separations, ( + f"no declared defect separates the vectors for {code!r}: every weakening " + f"either fools all of {sorted(bearing)} or none of them. The vectors are " + "mutually redundant — author one that catches a defect the others miss, or " + "declare a defect that tells them apart." + ) + + +# --------------------------------------------------------------------------- +# 5. The ratchet +# --------------------------------------------------------------------------- + + +def test_margins_have_not_thinned() -> None: + """A ratchet above the floor. The floor is two.""" + current = {code: len(_margin(code)) for code in RULE_CODES} + + if not MARGINS_FILE.exists(): + MARGINS_FILE.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n") + pytest.skip(f"recorded initial margins to {MARGINS_FILE.name}; re-run to enforce") + + recorded: dict[str, int] = json.loads(MARGINS_FILE.read_text(encoding="utf-8")) + thinned = { + key: (recorded[key], current[key]) + for key in recorded + if key in current and current[key] < recorded[key] + } + assert not thinned, ( + "coverage thinned for: " + + ", ".join(f"{k} {was}->{now}" for k, (was, now) in sorted(thinned.items())) + + ". Lowering a recorded margin is a decision to make on purpose, in this " + "commit, with a reason." + ) diff --git a/tests/test_delegation_vectors.py b/tests/test_delegation_vectors.py new file mode 100644 index 0000000..502b3b6 --- /dev/null +++ b/tests/test_delegation_vectors.py @@ -0,0 +1,559 @@ +"""Run the delegation-link corpus through a reference walk of the proposed A2A profile. + +The `delegation` block is normative in v0.2; what a verifier does with a chain of +them is not, and `docs/rfcs/a2a-delegation-profile.md` is the proposal that would +make it so. This module is that proposal executed: every rule below corresponds to +one numbered requirement in the RFC, and every vector under +`examples/delegation-link/` declares the outcome it expects before this code runs. + +The registry is the same shape as `test_action_receipt_fixtures.RULES`, for the +same reason: a check that is not registered never runs, so it cannot exist quietly +outside the inventory that `test_delegation_completeness.py` mutates. Nothing here +is exported from `agentrust_trace` — the package gains no public API for rules +that are not yet normative. + +Two orderings in the walk carry weight and are not incidental: + + `parent_not_found` cannot fire while the link's digest algorithm is one this + verifier does not implement. Reporting a chain invalid because of a link nobody + looked at is the downgrade-to-escape that `docs/verification.md` forbids in the + other direction: evidence that does not resolve is unverifiable, and only + evidence that resolves and contradicts is a failure. + + Provenance outranks authorization in the classification. A chain whose structure + is broken has no established parent to judge a credential against, so reporting + an authorization failure over it would be describing a relationship that was + never demonstrated. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agentrust_trace.validate import iter_errors + +VECTOR_DIR = Path(__file__).resolve().parents[1] / "examples" / "delegation-link" + +CLASSIFICATIONS = frozenset( + {"verified", "provenance-invalid", "authorization-invalid", "unverifiable"} +) +"""Every outcome the walk can return. ca2a's Group 7 three-way classification with +`unverifiable` kept distinct from both invalid kinds; collapsing any two of these +is the nonconformance the classification exists to name.""" + + +@dataclass(frozen=True) +class ChainResult: + classification: str + failures: list[str] + warnings: list[str] + depth: int + + def __post_init__(self) -> None: + assert self.classification in CLASSIFICATIONS, ( + f"undeclared outcome {self.classification!r}" + ) + + @property + def codes(self) -> list[str]: + return sorted(set(self.failures) | set(self.warnings)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _canonical(value: dict[str, Any]) -> bytes: + return rfc8785.dumps(value) + + +def _digest(record: dict[str, Any], alg: str) -> str: + """The profile's preimage: the complete record, signature included. + + The alternative reading — the signed body alone — is what vector 05 is built + on, and the RFC states why it is rejected: a body digest does not bind the + parent's signer, so anyone may re-sign identical bytes and satisfy the child's + commitment. + """ + return f"{alg}:" + hashlib.new(alg, _canonical(record)).hexdigest() + + +def _decode_b64u(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _signature_invalid(record: dict[str, Any]) -> bool: + """Does `record` fail verification under the key it advertises in `cnf.jwk`? + + The claimed key, not a trusted one: whether the key is trusted is a separate + question with its own rule, and merging them produces a verifier that cannot + tell "forged" from "signed by someone you have not heard of".""" + jwk = record.get("cnf", {}).get("jwk", {}) + if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or "x" not in jwk: + return True + body = {k: v for k, v in record.items() if k != "signature"} + try: + Ed25519PublicKey.from_public_bytes(_decode_b64u(jwk["x"])).verify( + _decode_b64u(record["signature"]), _canonical(body) + ) + except (InvalidSignature, ValueError, KeyError): + return True + return False + + +def _jwk_key(jwk: dict[str, Any]) -> tuple[Any, ...]: + """A hashable identity for a JWK, over key material only. + + `kid` and other members are advisory; two records naming the same curve point + hold the same key whatever else they say about it.""" + return (jwk.get("kty"), jwk.get("crv"), jwk.get("x"), jwk.get("y")) + + +# --------------------------------------------------------------------------- +# The rule registry +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Hop: + """One position in the walk, with everything a rule may read. + + `parent` is populated only on the `link` path, after the parent record has + actually been resolved. A rule that needs the parent and runs before it exists + would be reasoning about a hop that was never demonstrated.""" + + record: dict[str, Any] + context: dict[str, Any] + index: dict[str, dict[str, Any]] + depth: int + parent: dict[str, Any] | None = None + + @property + def delegation(self) -> dict[str, Any]: + return self.record["delegation"] + + @property + def link_algorithm(self) -> str: + return self.delegation["parent_record_hash"].split(":", 1)[0] + + @property + def credential(self) -> dict[str, Any] | None: + return self.context["credentials"].get(self.delegation["credential_id"]) + + +@dataclass(frozen=True) +class Rule: + """One named obligation. ``check`` returns True when the defect it guards + against is observed at this hop — True means the code is emitted.""" + + code: str + severity: str # "failure" | "warning" + klass: str # "provenance" | "authorization" | "unverifiable" + path: str # "record" | "root" | "resolve" | "link" + check: Callable[[Hop], bool] = field(compare=False) + + +def _record_signature_invalid(hop: Hop) -> bool: + return _signature_invalid(hop.record) + + +def _root_key_untrusted(hop: Hop) -> bool: + trusted = {_jwk_key(j) for j in hop.context["trusted_root_keys"]} + return _jwk_key(hop.record.get("cnf", {}).get("jwk", {})) not in trusted + + +def _depth_exceeded(hop: Hop) -> bool: + return hop.depth > hop.context["max_depth"] + + +def _digest_algorithm_unsupported(hop: Hop) -> bool: + return hop.link_algorithm not in hop.context["supported_digest_algorithms"] + + +def _parent_not_found(hop: Hop) -> bool: + # Guarded on support: a link this verifier cannot compute is unread, not + # broken, and saying "not found" about it would be a finding nobody made. + if _digest_algorithm_unsupported(hop): + return False + return hop.delegation["parent_record_hash"] not in hop.index + + +def _credential_unknown(hop: Hop) -> bool: + return hop.credential is None + + +def _credential_issuer_mismatch(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + assert hop.parent is not None + return cred["issuer"] != hop.parent["subject"] + + +def _credential_holder_mismatch(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + return cred["holder"] != hop.record["subject"] + + +def _credential_window(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + # Judged at the hop's own `iat`. A chain does not expire because it is being + # read late, and a verifier using its own clock reports a different answer + # every day for the same evidence. + return not (cred["not_before"] <= hop.record["iat"] <= cred["not_after"]) + + +def _data_class_widened(hop: Hop) -> bool: + assert hop.parent is not None + lattice: list[str] = hop.context["data_class_lattice"] + if hop.record["data_class"] not in lattice or hop.parent["data_class"] not in lattice: + # A class outside the supplied ordering is not comparable. It is not this + # rule's business to invent a position for it. + return False + return lattice.index(hop.record["data_class"]) > lattice.index(hop.parent["data_class"]) + + +RULES: tuple[Rule, ...] = ( + # -- every record on the chain ---------------------------------------------- + Rule("record_signature_invalid", "failure", "provenance", "record", + _record_signature_invalid), + # -- the record with no delegation block ------------------------------------- + Rule("root_key_untrusted", "failure", "provenance", "root", _root_key_untrusted), + # -- following a link, before the parent is known ---------------------------- + Rule("depth_exceeded", "failure", "authorization", "resolve", _depth_exceeded), + Rule("digest_algorithm_unsupported", "warning", "unverifiable", "resolve", + _digest_algorithm_unsupported), + Rule("parent_not_found", "failure", "provenance", "resolve", _parent_not_found), + # -- the hop, once its parent has been resolved ------------------------------ + Rule("credential_unknown", "failure", "authorization", "link", _credential_unknown), + Rule("credential_issuer_mismatch", "failure", "authorization", "link", + _credential_issuer_mismatch), + Rule("credential_holder_mismatch", "failure", "authorization", "link", + _credential_holder_mismatch), + Rule("credential_window", "failure", "authorization", "link", _credential_window), + Rule("data_class_widened", "failure", "authorization", "link", _data_class_widened), +) + + +def _evaluate( + hop: Hop, rules: Sequence[Rule], path: str +) -> tuple[list[str], list[str]]: + """The single point where rule codes are emitted. + + Everything the walk reports flows through this loop, which is what makes the + registry authoritative. `test_delegation_completeness` asserts by AST that no + other function in this module appends to a failure or warning list. + """ + failures: list[str] = [] + warnings: list[str] = [] + for rule in rules: + if rule.path == path and rule.check(hop): + (failures if rule.severity == "failure" else warnings).append(rule.code) + return failures, warnings + + +# --------------------------------------------------------------------------- +# The walk +# --------------------------------------------------------------------------- + + +def verify_chain(vector: dict[str, Any], rules: Sequence[Rule] = RULES) -> ChainResult: + """Walk `vector` from its designated leaf towards the root. + + The record set is a set: it arrives in no useful order, the leaf is named by + digest rather than by position, and a parent is found by looking its digest up + rather than by taking the next element. An implementation that reads + `records[0]` as the root agrees with this one on nothing. + + No cycle check, deliberately. A cycle needs each record's delegation block to + carry a digest covering the block that names it back, which is a hash + collision; the reachable analogue is an unbounded chain, and that is what + `depth_exceeded` is for. The RFC states this rather than leaving a reader to + wonder which of the two was forgotten. + """ + context = vector["context"] + records = vector["records"] + + index: dict[str, dict[str, Any]] = {} + for algorithm in context["supported_digest_algorithms"]: + for record in records: + index[_digest(record, algorithm)] = record + + current = index.get(context["leaf"]) + assert current is not None, ( + f"{vector['id']}: the record under appraisal is not in the vector's own " + "record set, or is not addressable under a supported digest algorithm" + ) + + failures: list[str] = [] + warnings: list[str] = [] + depth = 0 + visited: set[int] = set() + + while True: + hop = Hop(record=current, context=context, index=index, depth=depth) + + found, warned = _evaluate(hop, rules, "record") + failures += found + warnings += warned + + if "delegation" not in current: + found, warned = _evaluate(hop, rules, "root") + failures += found + warnings += warned + break + + depth += 1 + hop = replace(hop, depth=depth) + found, warned = _evaluate(hop, rules, "resolve") + failures += found + warnings += warned + + # Whether the walk can continue is a structural fact about the records, + # asked here directly. The registry says *why* a link could not be + # followed; it does not decide whether one was. Deriving the control flow + # from "did any rule fire" instead couples the walk to the registry's + # contents, and the completeness suite's whole method is to run this + # function with rules removed — under which that walk stepped off the end + # of the index and raised, rather than reporting a changed outcome. + # One reason only: the parent could not be resolved. The depth bound is + # deliberately *not* repeated here. Repeating it stops the walk at the same + # record whether or not `depth_exceeded` is registered, so a weakened bound + # never gets to walk further than a correct one, and the two depth vectors + # move together under every mutation — margin without independence, which + # is exactly what #124 says a second vector must not be. + parent = None + if hop.link_algorithm in context["supported_digest_algorithms"]: + parent = index.get(current["delegation"]["parent_record_hash"]) + if parent is None: + break + + hop = replace(hop, parent=parent) + found, warned = _evaluate(hop, rules, "link") + failures += found + warnings += warned + + # Not a conformance rule, and it cannot fire on a chain anyone can build: + # a cycle would need a record's delegation block to hold a digest of the + # block that names it back. It is here so that this function terminates on + # any input at all, including one hand-edited into a shape the hashes + # forbid, rather than terminating because the corpus happens to be honest. + if id(parent) in visited: + break + visited.add(id(parent)) + + current = parent + + if any(rule.klass == "provenance" for rule in rules if rule.code in failures): + classification = "provenance-invalid" + elif failures: + classification = "authorization-invalid" + elif warnings: + classification = "unverifiable" + else: + classification = "verified" + + return ChainResult( + classification=classification, + failures=failures, + warnings=warnings, + depth=depth, + ) + + +# --------------------------------------------------------------------------- +# The conformance run +# --------------------------------------------------------------------------- + +VECTOR_PATHS = sorted(VECTOR_DIR.glob("*.json")) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_vector_set_is_complete() -> None: + """A glob that silently loses a file passes every test parametrised on it.""" + assert [path.name for path in VECTOR_PATHS] == [ + "01-valid-single-hop.json", + "02-valid-full-depth-out-of-order.json", + "03-valid-root-only.json", + "04-parent-record-absent.json", + "05-link-over-signed-body.json", + "06-leaf-signed-by-other-key.json", + "07-intermediate-signed-by-other-key.json", + "08-root-key-untrusted.json", + "09-trusted-key-below-the-root.json", + "10-credential-not-registered.json", + "11-credential-id-case-differs.json", + "12-credential-issued-by-third-party.json", + "13-credential-self-issued.json", + "14-credential-held-by-third-party.json", + "15-credential-holder-is-the-parent.json", + "16-credential-expired-at-hop.json", + "17-credential-not-yet-valid-at-hop.json", + "18-data-class-widened-at-leaf.json", + "19-data-class-widened-mid-chain.json", + "20-depth-far-past-the-bound.json", + "21-depth-one-past-the-bound.json", + "22-leaf-link-uses-sha384.json", + "23-deep-link-uses-sha384.json", + ] + + +def test_vector_ids_match_their_filenames() -> None: + """The id is the cross-reference surface and the filename is how anyone finds + the file; a vector renumbered in one and not the other is citable under a name + that leads somewhere else.""" + for path in VECTOR_PATHS: + number = int(path.stem.split("-", 1)[0]) + assert _load(path)["id"] == f"TRACE-DELEG-{number:03d}", path.name + + +def test_every_vector_is_emitted_leaf_first() -> None: + """Not decoration. The set is emitted in the order least likely to be right for + an implementation that reads position, so that such an implementation fails on + the first vector rather than on the first shuffled input in production.""" + for path in VECTOR_PATHS: + vector = _load(path) + assert _digest(vector["records"][0], "sha256") == vector["context"]["leaf"], ( + f"{path.name}: the first emitted record is not the leaf" + ) + + +def test_each_vector_holds_exactly_one_root() -> None: + """Two roots is two chains, and a walk that reaches either would be judging a + record set nobody meant to present. Vector 04 drops a record from the middle, + which leaves the root intact and the chain broken — the defect it is for.""" + for path in VECTOR_PATHS: + vector = _load(path) + roots = [r for r in vector["records"] if "delegation" not in r] + assert len(roots) == 1, f"{path.name}: {len(roots)} records without a delegation block" + + +def test_the_trusted_key_placement_that_separates_08_from_09() -> None: + """Both vectors are 'the root is not trusted', and only one of them catches an + implementation that anchors on any trusted key it can find. That difference is + a property of where the keys sit, not of the walk, so it is pinned here: 09 has + a trusted key below its root and 08 has none anywhere. Lose either half and the + pair collapses into two copies of one test.""" + for name, trusted_key_present in ( + ("08-root-key-untrusted.json", False), + ("09-trusted-key-below-the-root.json", True), + ): + vector = _load(VECTOR_DIR / name) + trusted = {_jwk_key(jwk) for jwk in vector["context"]["trusted_root_keys"]} + held = {_jwk_key(r.get("cnf", {}).get("jwk", {})) for r in vector["records"]} + root = next(r for r in vector["records"] if "delegation" not in r) + assert _jwk_key(root["cnf"]["jwk"]) not in trusted, f"{name}: the root is trusted" + assert bool(trusted & held) is trusted_key_present, ( + f"{name}: expected trusted key present={trusted_key_present} on the chain" + ) + + +def test_vector_ids_are_unique_and_ordered() -> None: + """IDs are the cross-reference surface with ca2a's ACTION-* set and are never + reused. A duplicate would silently retire whichever case is read second.""" + ids = [_load(path)["id"] for path in VECTOR_PATHS] + assert len(set(ids)) == len(ids), "duplicate vector id" + assert ids == sorted(ids), "vector ids do not follow file order" + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_every_record_is_schema_valid(path: Path) -> None: + """A defect the schema already rejects is not a profile defect. + + Every record in every vector — including the ones built to fail — validates + against `trace-claim.json`. Otherwise a rule here could be "passing" only + because its vector is malformed in some louder, unrelated way. + """ + vector = _load(path) + for position, record in enumerate(vector["records"]): + errors = list(iter_errors(record)) + assert not errors, ( + f"{vector['id']} record {position} is not schema-valid: " + f"{[e.message for e in errors][:3]}" + ) + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_vector_reaches_its_declared_outcome(path: Path) -> None: + vector = _load(path) + result = verify_chain(vector) + expected = vector["expected"] + assert result.classification == expected["classification"], ( + f"{vector['id']} ({vector['name']}): expected " + f"{expected['classification']}, got {result.classification} " + f"with {result.codes}" + ) + assert result.codes == sorted(expected["codes"]), ( + f"{vector['id']} ({vector['name']}): expected codes " + f"{sorted(expected['codes'])}, got {result.codes}" + ) + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_outcome_is_independent_of_record_order(path: Path) -> None: + """The record set is a set. + + Every vector is emitted leaf-first already, so this reverses it back to + root-first and rotates it, and asserts the walk is unmoved. A verifier that + reads position — `records[0]` as the root, or the next element as the parent — + passes the corpus in one ordering and fails in another, and which one it met + first is not a property of the implementation. + """ + vector = _load(path) + baseline = verify_chain(vector) + records = vector["records"] + for permutation in (list(reversed(records)), records[1:] + records[:1]): + shuffled = {**vector, "records": permutation} + assert verify_chain(shuffled).codes == baseline.codes + assert verify_chain(shuffled).classification == baseline.classification + + +def test_the_corpus_exercises_every_classification() -> None: + """Three-way classification is the contract; a corpus that never produces one + of the outcomes cannot tell a verifier that collapses it from one that does.""" + produced = {verify_chain(_load(path)).classification for path in VECTOR_PATHS} + assert produced == CLASSIFICATIONS, f"never produced: {sorted(CLASSIFICATIONS - produced)}" + + +def test_the_rejected_digest_reading_is_what_vector_05_isolates() -> None: + """Vector 05's whole value is that it separates two readings of one sentence. + + If the leaf's link happened to resolve under the profile's reading too, the + vector would be testing nothing and no one would notice, because it would + still report `parent_not_found` for some other reason. This pins the + counterfactual: recomputing the link over the parent's signed body finds the + parent exactly, which is what makes the vector a boundary rather than a + coincidence. + """ + vector = _load(VECTOR_DIR / "05-link-over-signed-body.json") + leaf = next(r for r in vector["records"] if "delegation" in r) + parent = next(r for r in vector["records"] if "delegation" not in r) + claimed = leaf["delegation"]["parent_record_hash"] + + body = {k: v for k, v in parent.items() if k != "signature"} + body_digest = "sha256:" + hashlib.sha256(_canonical(body)).hexdigest() + + assert claimed == body_digest, "vector 05 no longer links over the signed body" + assert claimed != _digest(parent, "sha256"), ( + "the two readings agree on this vector, which makes it a boundary that " + "does not separate anything" + ) From 1b3ce78717086b944253e50b5e52632b0dd7168b Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:16:58 +0000 Subject: [PATCH 2/8] rfc: record what running the corpus through cA2A returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposal argued for cross-verification and did not do any, which left its central section a plan. This runs the 23 vectors against `ca2a_verify.verify_trace_dag` at ca2a 5dd77b2 and writes down what came back, including the parts that went against the draft. The §4.1 digest decision is confirmed from outside this repository. `ca2a_runtime.trace_binding.trace_record_hash` computes the sha256 of the complete signed record's RFC 8785 bytes — byte-identical to what the profile specifies, arrived at separately. Vectors 01-07 agree in verdict and in reason. Vectors 22 and 23 disagree exactly as §4.3 predicted: cA2A accepts a `sha384:` link at block validation, compares it against a hash it only ever computes as `sha256:`, and reports the chain as "a tampered or reparented record". An intact chain addressed under the other permitted algorithm is reported as tampering. The distinction between unreadable and contradicted is now observed rather than argued. Two things the draft got wrong, corrected here rather than left standing: It said cA2A "states that its credentials are cross-verifiable with agent-manifest" and that nothing tests the claim. The claim in `ca2a_runtime/canonical.py` is narrower — that RFC 8785 makes the signed byte string identical across conforming implementations, so signatures verify either side. Read as credential interoperability it is a claim ca2a does not make. Checked on the axis it does make: ca2a hand-implements JCS rather than taking a library, and that implementation is byte-identical to the reference on all four vectors of `examples/canonicalization-boundary/`, both UTF-16 key-order cases included. Upheld. A first pass recorded that cA2A has no depth bound. It has one — `max_depth`, default 8, on the credential chain rather than on the record DAG. A bound in a different place is not an absent bound. The credential surfaces turn out not to be comparable at all, which is the finding rather than an obstacle to it: three repositories, three delegation models, no conversion between them. §7.1 tabulates them. Two consequences worth carrying forward — agent-manifest already narrows on `data_classifications`, which is independent support for D-9 belonging on this surface, and cA2A credentials carry no validity window at all, so D-8 has no counterpart there. The trust contract also differs and cannot be normalised away: `verify_trace_dag` requires every record's key to be trusted, this profile anchors on the root's. Under this profile's contract cA2A rejects every valid chain longer than one record. Neither is wrong; they fit different deployments, and cA2A itself uses the root-anchored model on its other surface. No code changes. The vectors are untouched and both suites still pass. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- docs/rfcs/a2a-delegation-profile.md | 65 +++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index 388a90a..9a27708 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -214,9 +214,8 @@ independently written verifiers. Both exist. `agentrust-io/ca2a` verifies delegation DAGs offline in `ca2a_verify`; `agentrust-io/agent-manifest` exports `verify_delegation_chain`, `DelegationHopSigner` and -`delegation_depth_exceeded` from its public API. `ca2a/src/ca2a_runtime/delegation/credential.py` -states that its credentials are "cross-verifiable with agent-manifest". Nothing in any of the -three repositories tests that claim. +`delegation_depth_exceeded` from its public API. This corpus has been run against the first +of them; what came back is in §7.1, and it is the reason this section is the load-bearing one. The proposal for the next step, which is not this document's to decide: @@ -232,6 +231,66 @@ The same corpus would also close a gap on the agent-manifest side: of its 21 vec touch delegation and both are single-hop, so its own narrowing and depth logic has no vector coverage at all. +### 7.1 What running it against cA2A actually returned + +Measured against `ca2a_verify.verify_trace_dag` at ca2a `5dd77b2`. Thirteen of the 23 +vectors exercise the record-linkage surface that function covers; the other ten are +credential defects, which its own docstring assigns to `ca2a_runtime.delegation.verify_chain`. + +**The digest decision in §4.1 is confirmed independently.** `ca2a_runtime.trace_binding.trace_record_hash` +computes `"sha256:" + sha256(rfc8785.dumps(signed_record))` — the complete record, signature +included, byte-identical to what this profile specifies. That was arrived at separately, in +another repository, and it is the strongest evidence available that §4.1 chose the reading +the ecosystem is already built on rather than the one that was convenient here. Vector 05, +the body-digest link, is rejected by cA2A as a broken parent link. + +**Vectors 01–07 agree.** Valid chains are accepted, the absent parent and the body-digest +link are rejected as broken links, and both signature vectors are rejected as bad +signatures. Same verdict, same reason, two implementations. + +**Vectors 22 and 23 disagree, exactly as §4.3 predicts.** cA2A's block validator accepts a +`sha384:` link, then compares it against a hash it only ever computes as `sha256:`, and +reports `ProvenanceLinkBroken` with the detail *"a tampered or reparented record was +detected"*. A chain that is intact and simply addressed under the other permitted algorithm +is reported as tampering. This is the difference between unreadable and contradicted, +observed rather than argued, and it is the clearest single reason the distinction needs to +be written down somewhere normative. + +**The trust contract differs and cannot be normalised away.** `verify_trace_dag` requires +every record's `cnf.jwk` to be in the trusted set; this profile anchors on the root's key and +lets the chain carry the rest. Run under this profile's contract — only the declared root +trusted — cA2A rejects every chain longer than one record, valid ones included. Neither is +wrong: cA2A's model fits a workflow whose orchestrator knows every participant, and this +one fits the cross-organisation case where that knowledge is exactly what is missing. It is +worth noting that cA2A itself uses the root-anchored model on its other surface, where +`verify_chain` takes `trusted_root_issuers`. + +**The credential surfaces cannot be compared at all, and that is the finding.** Three +repositories, three delegation models, no conversion between them: + +| | this profile | cA2A `DelegationCredential` | agent-manifest hop | +|---|---|---|---| +| issuer / subject | record `subject` (SPIFFE) | raw Ed25519 public key hex | `principal_id` | +| scope | `data_class` + supplied lattice | `scope: frozenset[str]` | tools, `data_classifications`, constraints, `ttl_seconds` | +| validity window | `not_before` / `not_after` vs hop `iat` | **absent** | `ttl_seconds`, narrowing | +| depth bound | verifier context | on each credential, default 8 | `max_delegation_depth` on the root hop | +| trust anchor | root record's key | `trusted_root_issuers` | `public_keys` map of every principal | +| replay binding | — | `parent_id` chaining, unique ids | `manifest_id` in the signature pre-image | + +Two things follow. agent-manifest already narrows on `data_classifications`, which is +independent support for D-9 belonging on this surface at all — open question 2 above. And +cA2A credentials carry no validity window, so D-8 has no counterpart there; an authority +that cannot expire is the same family of gap as the two that issue #66 has already named. + +**One claim, checked and upheld.** `ca2a_runtime/canonical.py` hand-implements RFC 8785 +rather than taking a library, and states that this makes cA2A signatures cross-verifiable +with agent-manifest. Run against `examples/canonicalization-boundary/`, which exists to +separate a conforming serializer from a carefully configured `json.dumps`, that +implementation is byte-identical to the reference on all four vectors — including both +UTF-16 key-order cases, which is where near-misses fail. The claim is narrower than +"the two verifiers agree on a chain": it is about the signed byte string, and on that axis +it holds. + ## 8. Open questions 1. **Is the credential registry the right shape?** It is modelled here as verifier context From 59a2fb5d9fbc9e12373db6015f37a316430b4d84 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:31:16 +0000 Subject: [PATCH 3/8] rfc: run the corpus in the reverse direction, and read agent-manifest's outcomes The cross-check so far only pushed this corpus outward, which shows that cA2A rejects what the profile rejects and nothing about whether the profile describes what the ecosystem emits. This runs it the other way and reads the third implementation's declared outcomes. `ca2a/examples/trace-dag/demo.py` emits a signed three-hop TRACE DAG through cA2A's own `trace_binding`. Against it: three schema-valid records, both links matching the section 4.1 preimage exactly, all three signatures valid under D-1, and the chain returns `verified` with no codes and no adjustment to the walk. A chain produced by an independent implementation verifies here unchanged. agent-manifest turns out to settle section 4.3 rather than leave it open. Its corpus declares results as data in the vector files -- VALID, MISMATCH, UNVERIFIABLE, EXPIRED, REVOKED, SIGNATURE_MISSING, INCOMPLETE, INCOMPATIBLE_VERSION, ATTESTATION_UNAVAILABLE -- and AM-VEC-012 declares `{"result": "UNVERIFIABLE", "fields_verified": {"delegation_chain": "UNVERIFIABLE"}}` for a delegation chain with no public keys. Evidence the verifier lacks what it needs to check, recorded as unreadable rather than as a finding against the chain: section 4.3, on this surface, in a second implementation, arrived at independently. Two of the three distinguish unreadable from contradicted; cA2A's TRACE DAG verifier collapses them, which makes the sha384 divergence a gap rather than a preference. Its `fields_verified` shape is prior art this proposal does not have. A verdict per field says more than a verdict per chain, and section 8 should probably ask about it. Two smaller things recorded where they were found. `examples/trace-dag/` commits a README and a demo but no vectors -- the DAG is produced at runtime and not kept, which is the gap this corpus fills from the trace-spec side. And cA2A uses the field name `parent_record_hash` in two formats: the schema's prefixed digest in a TRACE record, and a bare hex digest in its own provenance DAG, on records carrying no TRACE fields. Both deliberate, neither wrong, and a hazard for anyone writing a parser against the name. No code changes; the vectors are untouched and both suites pass. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- docs/rfcs/a2a-delegation-profile.md | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index 9a27708..0dff3b7 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -282,6 +282,45 @@ independent support for D-9 belonging on this surface at all — open question 2 cA2A credentials carry no validity window, so D-8 has no counterpart there; an authority that cannot expire is the same family of gap as the two that issue #66 has already named. +**The reverse direction passes.** Everything above pushes this corpus outward, which only +shows that cA2A rejects what the profile rejects. The harder question is whether a chain the +*ecosystem produces* verifies here. `ca2a/examples/trace-dag/demo.py` emits a signed +three-hop TRACE DAG through cA2A's own `trace_binding`; run against it, all three records are +schema-valid, both links match the §4.1 preimage exactly, all three signatures verify under +D-1, and the chain returns `verified` with no codes and no adjustment to the walk. The +profile describes what is already being emitted rather than something invented alongside it. + +Worth recording while it was found: `examples/trace-dag/` commits a README and a demo but no +vectors — the DAG is produced at runtime and not kept. That is the gap this corpus fills from +the trace-spec side, and it is why §7's step 1 is a cross-reference table rather than a +request that cA2A publish one. + +Also worth recording, as a hazard for anyone writing a parser: cA2A uses the field name +`parent_record_hash` in two different formats. In a TRACE record it is the schema's +`sha256:`/`sha384:`-prefixed digest; in cA2A's own provenance DAG — `ca2a verify-dag`, and the +committed `examples/*/dag.json` — it is a bare hex digest with no prefix, on a record with +no TRACE fields at all. Both are deliberate and neither is wrong; the collision is in the +name. + +**agent-manifest already treats unverifiable as a first-class outcome, on this exact +surface.** Its corpus declares results as data in the vector files, over a vocabulary of +`VALID`, `MISMATCH`, `UNVERIFIABLE`, `EXPIRED`, `REVOKED`, `SIGNATURE_MISSING`, `INCOMPLETE`, +`INCOMPATIBLE_VERSION`, `ATTESTATION_UNAVAILABLE` — and `AM-VEC-012` reads: + +```json +{"result": "UNVERIFIABLE", "fields_verified": {"delegation_chain": "UNVERIFIABLE"}} +``` + +The case is a delegation chain with no public keys: evidence the verifier lacks what it needs +to check, recorded as unreadable rather than as a finding against the chain. That is §4.3, +on the delegation surface, in a second implementation, arrived at independently. + +Which puts the sha384 divergence in a different light than a matter of taste. Two of the +three implementations distinguish "could not be read" from "contradicts"; cA2A's TRACE DAG +verifier collapses the two and reports tampering. The per-field shape of `fields_verified` is +also prior art this proposal does not have and probably should consider: a verdict per field +says more than one verdict per chain. + **One claim, checked and upheld.** `ca2a_runtime/canonical.py` hand-implements RFC 8785 rather than taking a library, and states that this makes cA2A signatures cross-verifiable with agent-manifest. Run against `examples/canonicalization-boundary/`, which exists to From c6c9331258e6341cac12171eae1dc0cc758131eb Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:08:00 +0000 Subject: [PATCH 4/8] rfc: name the two things a Project Lead would notice first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are gaps in this document rather than in anything it argues, and both were found by reading `ROADMAP.md:21` against §6 rather than by anyone raising them. **The mutual case.** That line scopes the v0.3 A2A profile as "binding rules over the `delegation` block ... including the mutual case". §6 lists six things this proposal does not do and omitted the one the roadmap names. Nothing here covers mutual delegation: every rule walks one chain in one direction, and the block as it stands names one parent and no peer. Calling two agents each holding the other's authority "two chains" would be deciding that question rather than raising it, so §6 now says so plainly. It is the largest distance between the roadmap's line and this document. **Who this is for.** The same line names cA2A as the reference implementation and says nothing about who writes the binding rules, and this was written without asking. §8 opens with that question ahead of the design ones, because the answer changes what the document should become: the profile itself would need the mutual case, a credential model and a ratification path; an input stays a set of rules with executable material behind them, liftable or discardable a rule at a time. Neither is a change to a rule, a vector or a suite. 585 passed. --- docs/rfcs/a2a-delegation-profile.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index 0dff3b7..7acf0ca 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -204,6 +204,14 @@ first time either side adds a case. §7 proposes a cross-reference table instead of the two gaps issue #66 has already named on the approval-shaped surface; it needs a field this schema does not have. - **No authority-epoch staleness**, the other #66 gap, for the same reason. +- **No mutual case.** `ROADMAP.md:21` scopes the v0.3 A2A profile as "binding rules over the + `delegation` block now that A2A is stable at v1.x, **including the mutual case**". Nothing + here covers mutual delegation: every rule below walks one chain in one direction, from a leaf + towards a root, and the `delegation` block as it stands names one parent and no peer. Two + agents each holding authority delegated by the other is a shape this walk cannot express, + and pretending otherwise by calling it "two chains" would be deciding the question rather + than raising it. This is the largest distance between the roadmap's line and this document, + and it is stated here rather than left to be discovered. - **No cross-verifier agreement.** This is the load-bearing omission and §7 is about it. ## 7. The part that makes this worth doing @@ -332,6 +340,16 @@ it holds. ## 8. Open questions +The first one is not about the design. + +0. **Is this document the v0.3 profile, or an input to it?** `ROADMAP.md:21` places the + normative A2A profile at v0.3 and names cA2A as the reference implementation. It does not + say who writes the binding rules, and this was written without asking. Both answers are + fine and they lead to different documents: the profile itself would need the mutual case, + a credential model and a ratification path; an input would stay what it is, a set of rules + that already have executable material behind them and can be lifted, argued with, or + discarded a rule at a time. The corpus is useful either way, which is the reason it was + built first. 1. **Is the credential registry the right shape?** It is modelled here as verifier context because nothing in the schema describes a credential. The alternative — a credential object in the record — is a schema change and a much larger proposal. From 61a1aa18c63d8bb977e1e3c89f420a92582d8fa1 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:13:40 +0000 Subject: [PATCH 5/8] rfc: state the method, which was the reason for the order and went unwritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document presented the three decisions in §4 as decisions and never said how they were arrived at. They were not read out of `docs/schema.md`; they were hit, because no vector could be written without settling them, and in each case the text supports both branches. A reader passes over all three without noticing. Someone building a fixture cannot get to the end of one. That order -- corpus first, and let it interrogate the text -- is the part worth keeping if every rule here is replaced, because it yields a measurement this repository does not otherwise have. Not whether tests cover the rules, which measures an implementation, but whether two independent readings of the same normative text produce the same rules, which measures the specification. Agreement means the text is doing its job; divergence names the sentence that is missing. §7.1 was already that measurement run once and was not labelled as one. Two implementations written without reference to this document agree with §4.1 and with each other on the digest preimage, and split on the unresolvable-algorithm question -- one calling it unreadable, one calling it tampering. The first result is the text working. The second is a located gap that took no argument to find, because the same question was put to two implementations rather than debated. It also settles what a second profile design would be for. One reading measures nothing, so an independently written set of binding rules is the experiment, not a collision with this one. No rule, vector or suite changed. 585 passed. --- docs/rfcs/a2a-delegation-profile.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index 7acf0ca..fb266a6 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -11,6 +11,26 @@ implementation; this file is informative until its rules are adopted, at which p become uppercase in `spec/` and this file becomes a pointer to where they went. A proposal that writes itself in the imperative is a specification nobody agreed to. +**Why the corpus was built before the rules.** The three decisions in §4 were not read out of +the existing text. They were hit, because no vector could be written without settling them, and +in each case the text supports both branches. A reader passes over all three without noticing; +someone building a fixture cannot get to the end of one. That order — corpus first, and let it +interrogate the text — is the part of this document worth keeping if the rules themselves are +replaced, because it produces a measurement the repository does not otherwise have. + +Not *do the tests cover the rules*, which measures an implementation. **Do two independent +readings of the same normative text produce the same rules**, which measures the +specification. Where two readings agree, the text is doing its job; where they diverge, the +text is under-specified, and the divergence names the sentence that is missing. §7.1 is that +measurement run once, on two implementations that were written without reference to this +document: on the digest preimage they agree with §4.1 and with each other, and on the +unresolvable-algorithm question they split — one treats it as unreadable and one as tampering. +The first is the text working. The second is a missing sentence, located, in one line, and it +took no argument to find because two implementations were asked the same question. + +A single reading measures nothing. That is the case for a second profile design rather than +against one. + --- ## 1. What exists today From bfdeb9a06eb8930845a54dc683994ba6b8f14350 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:31:36 +0000 Subject: [PATCH 6/8] rfc: correct what the roadmap's "mutual case" refers to The previous entry read that line as mutual delegation and reported this document as short of it. That was a guess at the referent, made without checking, and it is wrong. In the reference implementation the mutual case is mutual attestation. ca2a/docs/spec/mutual-attestation.md describes a callee-issued challenge and a caller offer bound to it, so each side establishes what the other is running before a payload opens. It separates the two concerns explicitly -- it "establishes what each side is running", while "the delegation chain remains the thing that says what it is allowed to ask for" -- and it does not mention a Trust Record anywhere. Nor does cA2A's own docs/spec/trace-a2a-profile.md, whose A2A profile is the delegation-link block and nothing else. Which changes what the entry says. The roadmap asks the A2A profile to cover something with no record representation today in either repository, sitting at the transport layer rather than on this surface. That is a scoping question -- either mutual attestation gains a binding into the record, which is a schema question rather than a verification one, or the v0.3 profile is two profiles -- and not a coverage failure in these rules. A reader comparing this document against that roadmap line would otherwise conclude the second. The bidirectional-delegation reading is kept as a separate note rather than dropped, because it is true and unreachable for the reason section 4.2 gives, and because the two readings should not merge later. Every claim above traced to its file before writing: grep for "trace" in mutual-attestation.md returns 0, grep for "mutual" in trace-a2a-profile.md returns 0, and both quotations were checked against the source with whitespace normalised, since the file wraps mid-sentence and a single-line grep misses them. No rule, vector or suite changed. 585 passed, ruff clean. --- docs/rfcs/a2a-delegation-profile.md | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index fb266a6..3a5e1d3 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -224,14 +224,29 @@ first time either side adds a case. §7 proposes a cross-reference table instead of the two gaps issue #66 has already named on the approval-shaped surface; it needs a field this schema does not have. - **No authority-epoch staleness**, the other #66 gap, for the same reason. -- **No mutual case.** `ROADMAP.md:21` scopes the v0.3 A2A profile as "binding rules over the - `delegation` block now that A2A is stable at v1.x, **including the mutual case**". Nothing - here covers mutual delegation: every rule below walks one chain in one direction, from a leaf - towards a root, and the `delegation` block as it stands names one parent and no peer. Two - agents each holding authority delegated by the other is a shape this walk cannot express, - and pretending otherwise by calling it "two chains" would be deciding the question rather - than raising it. This is the largest distance between the roadmap's line and this document, - and it is stated here rather than left to be discovered. +- **No mutual case — and it is not clear the delegation block is where it belongs.** + `ROADMAP.md:21` scopes the v0.3 A2A profile as "binding rules over the `delegation` block now + that A2A is stable at v1.x, **including the mutual case**". In the reference implementation + the mutual case is mutual *attestation*, not mutual delegation: `ca2a/docs/spec/mutual-attestation.md` + describes a callee-issued challenge and a caller offer bound to it, so each side establishes + what the other is running before a payload opens. That document separates the two concerns in + as many words — it "establishes what each side is running", while "the delegation chain + remains the thing that says what it is allowed to ask for" — and it does not mention a Trust + Record anywhere. Neither does cA2A's own `docs/spec/trace-a2a-profile.md`, whose A2A profile + is the delegation-link block and nothing else. + + So the roadmap asks the A2A profile to cover something that today has no record + representation at all, in either repository, and which sits at the transport layer rather + than on this surface. That is a scoping question rather than a hole in these rules: either + mutual attestation gains a binding into the record — which is a schema question, not a + verification one — or the v0.3 profile is two profiles. Raised here because a reader + comparing this document against that roadmap line will otherwise conclude the gap is a + coverage failure, and it is not the same thing. + + Bidirectional *delegation*, if that reading were ever intended, is separately unreachable: + A's block would have to carry a digest covering B's block and B's a digest covering A's, + which is the same hash collision §4.2 rules out for cycles. Recorded so the two readings do + not get merged later. - **No cross-verifier agreement.** This is the load-bearing omission and §7 is about it. ## 7. The part that makes this worth doing From b5934990adc7ab765b9b9b02038c5a4729a66155 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:58:16 +0000 Subject: [PATCH 7/8] test(adequacy): grade delegation-link by the criteria this repository merged #186 added criteria that every vector set on disk is measured against, and test_every_vector_set_on_disk_is_measured_somewhere fails for a set in neither SETS nor MEASURED_ELSEWHERE. This branch was opened three days before those criteria landed, so merging upstream leaves `delegation-link` as the one set nothing grades, and it is the only failure in the merged tree. Registered in SETS rather than named in MEASURED_ELSEWHERE, because the set holds up when it is actually graded rather than only pointed at: delegation-link: 23 vectors, 3 accepting, 10 distinct failure codes No shortfall on either criterion decidable from the fixtures. It is not satisfiable by an implementation that answers "accept" to everything or one that answers "reject" to everything, and every one of the ten failure codes is carried by exactly two vectors, which is the margin #124 asks for. Boundaries are counted by failure code, the default. adequacy.py says that assumption is the set's to justify: here the codes are the unit, because tests/delegation_margins.json records the per-code margin and tests/test_delegation_completeness.py holds each rule to being load-bearing for both of its vectors, deleting the rule from the registry rather than matching source text. The criteria adequacy.py leaves to each set, a rule nothing pins and a weakness shared across a boundary's vectors, are implemented there too, by rebuilding the registry without an entry and by substituting shortcut checks that read only the first link or the first hop. 605 passed, 1 skipped. Verified by removing the SETS entry again, which fails test_every_vector_set_on_disk_is_measured_somewhere on its own. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- tests/test_adequacy_all_sets.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index 234ca7b..bec5453 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -101,9 +101,23 @@ def _depth_boundary(v: Vector) -> tuple[str, ...]: return (v.boundary,) if v.boundary else () + +def delegation_link() -> list[Vector]: + """The delegation-link set, graded by the same criteria as every other set here. + + Its boundaries are its failure codes: `tests/delegation_margins.json` records the + two-vector margin per code, and `tests/test_delegation_completeness.py` holds each + rule to being load-bearing for both of them, so the default mapping is the set's + own unit rather than an assumption made here. + """ + return _load("delegation-link", + lambda e: e["classification"], lambda e: list(e.get("codes") or [])) + + SETS = { "build-provenance-depth": (build_provenance_depth, _depth_boundary), "canonicalization-boundary": (canonicalization_boundary, None), + "delegation-link": (delegation_link, None), } # Every set must be able to fail both unconditional implementations. A set that From ed10f933ae12962c9750a2fe9b99d357d2bf45d7 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:05:18 +0000 Subject: [PATCH 8/8] rfc: the sha384 divergence is closed, so stop reporting it as live Section 7.1 recorded the cA2A disagreement in the present tense: its block validator accepts a sha384: link, compares it against a hash it only computes as sha256:, and reports ProvenanceLinkBroken with "a tampered or reparented record was detected". That was true when the corpus was run and is not true now, so the document was carrying a defect report against another repository that the other repository has already fixed. Checked at ca2a 52141e8 rather than taken from the report: src/ca2a_verify/dag.py:194 raises TraceDigestUnsupported with the detail "the chain is unverifiable here, not invalid" at line 199, and the parent-link comparison that produced ProvenanceLinkBroken is at line 201, after it. So the guard precedes the comparison and vectors 22 and 23 now describe fixed behaviour. Both places are re-tensed rather than deleted. What the case establishes is not that one verifier had a bug: a corpus written to argue a rule found the case, the other implementation changed, and the shape it changed to is the distinction section 4.3 asks for. Deleting it would drop the strongest evidence in the document that the corpus does what it claims. The second passage said two of three implementations distinguish unreadable from contradicted; it is now all three. Section 4.3's rule text is untouched, since it states the rule rather than reporting on an implementation. 605 passed, 1 skipped. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- docs/rfcs/a2a-delegation-profile.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index 3a5e1d3..c5797df 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -291,13 +291,19 @@ the body-digest link, is rejected by cA2A as a broken parent link. link are rejected as broken links, and both signature vectors are rejected as bad signatures. Same verdict, same reason, two implementations. -**Vectors 22 and 23 disagree, exactly as §4.3 predicts.** cA2A's block validator accepts a -`sha384:` link, then compares it against a hash it only ever computes as `sha256:`, and -reports `ProvenanceLinkBroken` with the detail *"a tampered or reparented record was -detected"*. A chain that is intact and simply addressed under the other permitted algorithm -is reported as tampering. This is the difference between unreadable and contradicted, -observed rather than argued, and it is the clearest single reason the distinction needs to -be written down somewhere normative. +**Vectors 22 and 23 disagreed, exactly as §4.3 predicts, and the disagreement is closed.** +cA2A's block validator accepted a `sha384:` link, compared it against a hash it only ever +computes as `sha256:`, and reported `ProvenanceLinkBroken` with the detail *"a tampered or +reparented record was detected"*: a chain that is intact, and simply addressed under the +other permitted algorithm, reported as tampering. + +[agentrust-io/ca2a#119](https://github.com/agentrust-io/ca2a/pull/119) carried the fix. At +ca2a `52141e8`, `src/ca2a_verify/dag.py` raises `TraceDigestUnsupported` with the detail +*"the chain is unverifiable here, not invalid"* before the link comparison is reached, so +the two vectors now describe fixed behaviour and agree with the implementation as well as +with the ruling. The case is kept here because of what it establishes rather than as a +defect report: a corpus written to argue a rule found the case, the other implementation +changed, and the shape it changed to is the one §4.3 asks for. **The trust contract differs and cannot be normalised away.** `verify_trace_dag` requires every record's `cnf.jwk` to be in the trusted set; this profile anchors on the root's key and @@ -358,9 +364,10 @@ The case is a delegation chain with no public keys: evidence the verifier lacks to check, recorded as unreadable rather than as a finding against the chain. That is §4.3, on the delegation surface, in a second implementation, arrived at independently. -Which puts the sha384 divergence in a different light than a matter of taste. Two of the -three implementations distinguish "could not be read" from "contradicts"; cA2A's TRACE DAG -verifier collapses the two and reports tampering. The per-field shape of `fields_verified` is +Which puts the sha384 case in a different light than a matter of taste. All three +implementations now distinguish "could not be read" from "contradicts"; cA2A's TRACE DAG +verifier collapsed the two until #119, and what it changed to is the distinction §4.3 asks +for. The per-field shape of `fields_verified` is also prior art this proposal does not have and probably should consider: a verdict per field says more than one verdict per chain.