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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

## [Unreleased]

### Added

- **`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

- **Release gates now fail closed.** CodeQL analysis failures block instead of being ignored. Before trusted PyPI publication, clean virtual environments install and verify both the built wheel and source distribution outside the checkout, checking tag/version identity, packaged schema resources, signing and verification, and rejection of unknown security fields.
Expand Down
6 changes: 3 additions & 3 deletions docs/build-provenance-depth.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: What each build_provenance verification depth does not assure. Surf

# Build provenance verification depth

> **Non-normative.** This page is informative. It changes no schema field, wire format, required claim or conformance requirement, and carries no uppercase RFC 2119 keyword. The depth question itself is [trace-spec#50](https://github.com/agentrust-io/trace-spec/issues/50), labelled `policy: take-internal`; nothing here anticipates its outcome. The names *Surface*, *Builder-chain* and *Dependency-chain* describe where verifiers stop today, not a proposed enum.
> **Non-normative.** This page is informative. It changes no schema field, wire format, required claim or conformance requirement, and carries no uppercase RFC 2119 keyword. The depth question itself was decided on [trace-spec#50](https://github.com/agentrust-io/trace-spec/issues/50). The names *Surface*, *Builder-chain* and *Dependency-chain* used on this page are the descriptive ones; they map one to one onto the `build_provenance.provenance_depth` values `surface`, `builder` and `transitive`. [Section 3.3.1 of the specification](../spec/trace-v0.2.md#331-build-provenance-verification-depth) carries the normative rules.

[Spec section 3.3](../spec/trace-v0.2.md) step 7 is one sentence: *"SLSA provenance resolves to a trusted builder."* It does not say how far the verifier walks, and three stopping points satisfy it. They are not equally strong, and the weakest is the cheapest to implement.

Expand Down Expand Up @@ -66,9 +66,9 @@ Nor does it assure the artifact was built from the source you think. A trusted b
|---|---|---|
| Surface | The artifact digest and a trusted-builder list | Nothing. It always runs. |
| Builder-chain | Attestation retrieval at verification time, or a cached bundle | A record whose `provenance_uri` is absent or unresolvable cannot be verified past surface |
| Dependency-chain | A publisher attestation per input, and ecosystem coverage for those inputs | Inputs from ecosystems without attestation coverage fail, benign ones included |
| Dependency-chain | A publisher attestation per input, and ecosystem coverage for those inputs | Inputs from ecosystems without attestation coverage cannot be verified past builder, benign ones included |

Deeper is not free, and the honest reason deployments stop early is usually not laziness. Dependency-chain verification fails closed on unattested inputs, and attestation coverage across package ecosystems is uneven, so a strict verifier rejects a lot of software that is fine.
Deeper is not free, and the honest reason deployments stop early is usually not laziness. An unattested input is unverifiable rather than incriminating, so [verification.md](verification.md) has the verifier record `builder` and name what it could not fetch; a deployment whose floor is `transitive` then treats that record as `contraindicated`. Attestation coverage across package ecosystems is uneven, so such a deployment turns away a lot of software that is fine — the strictness lives in the floor, not in a finding against the record.

That trade-off is a legitimate choice. Picking Surface because it was the cheapest to implement, without recording that "trusted builder" is then a claim nobody checked, is not the same choice.

Expand Down
2 changes: 2 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Build-time provenance binding the deployed artifact.
| `builder` | string | no | Builder identity URI (e.g., GitHub Actions SLSA generator) |
| `digest` | string | **yes** | `sha256:` digest of the built artifact |
| `provenance_uri` | string | no | URI to the SLSA provenance document (e.g., Rekor entry) |
| `provenance_depth` | string | no | Depth the issuer claims: `surface`, `builder` or `transitive`. Absent is read as `surface` |

## `appraisal`

Expand All @@ -120,6 +121,7 @@ Verifier judgment on the evidence in this record.
| `verifier` | string | **yes** | URI of the verifier that produced this appraisal |
| `policy_ref` | string | no | URI to the appraisal policy applied |
| `timestamp` | integer | no | Unix epoch seconds when appraisal was performed |
| `provenance_depth_verified` | string | no | Depth this verifier actually ran: `surface`, `builder` or `transitive` |

## `transparency`

Expand Down
2 changes: 1 addition & 1 deletion docs/trust-levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Level 1 requires that the signing key be generated inside a verified TEE (AMD SE
| `runtime.platform` | Must be `tpm2`, `sev-snp`, `tdx`, or `opaque` (not `software-only`) |
| `runtime.measurement` | Non-zero `sha256:` or `sha384:` digest of the TEE launch state |
| `appraisal.status` | Must be `affirming` (verifier has checked the TEE quote) |
| `build_provenance.slsa_level` | Must be present (0–3) |
| `build_provenance.slsa_level` | Must be present (0-3) |
| `build_provenance.digest` | Must be a valid `sha256:` digest |

The cMCP runtime handles Level 1 record emission automatically when running in a supported TEE. See [Hardware Attestation Platforms](tutorials/hardware-attestation-platforms.md).
Expand Down
83 changes: 82 additions & 1 deletion docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,87 @@ For Level 2 records (TEE-issued), additionally verify that the `cnf.jwk` key is

This chain proves the key that signed the TRACE record was generated *inside* the attested enclave — not by an operator process.

## Verifying build provenance depth

The normative rules are defined by [§3.3.1 of the specification](../spec/trace-v0.2.md).
`build_provenance.provenance_depth` declares how far down the supply chain the issuer claims to
have walked. A verifier records what it actually checked in
`appraisal.provenance_depth_verified`, which is a statement about the verifier, not about the
record.

| Claimed depth | Verifier checks | May downgrade to — evidence does not resolve | Fails — evidence resolves and contradicts |
|---|---|---|---|
| `surface` (or absent) | Confirm `digest` matches the workload artifact and `builder` resolves to the configured trusted-builder set. | Already the floor. | `digest` does not match the artifact the verifier independently holds, or `builder` is outside the trusted-builder set. |
| `builder` | All of surface, plus fetch `provenance_uri`, verify the SLSA attestation signature, check the attestation `subject` matches `digest`, and check the attestation `builder.id` matches `builder`. | `surface`, when `provenance_uri` is absent or unreachable, or its signature does not resolve. | The attestation resolves and its `subject` does not match `digest`, or its `builder.id` does not match `builder`. |
| `transitive` | All of builder, plus enumerate the SLSA `materials` / `resolvedDependencies` and confirm every entry has a verifiable publisher attestation (npm OIDC, PyPI Trusted Publisher, Sigstore Rekor entry, or platform equivalent). | `builder`, when an input carries no publisher attestation or the attestation declares no inputs at all; or `surface` per the row above. | An input's publisher attestation resolves and was signed under an issuer outside the configured trusted set. |

The two right-hand columns are disjoint, and which one applies turns on whether the evidence
resolved — not on how serious the finding is.

**Evidence that does not resolve** leaves a check unrun. The verifier may stop at the depth
below, then records that lower depth in `appraisal.provenance_depth_verified`, and does not
report the missing evidence as a failure of the record. A record is not defective because
someone else's transparency log is unreachable, and a verifier that rejects on this is failing
records for the weather.

**Evidence that resolves and contradicts the record** fails the appraisal. A verifier does not
downgrade to escape it. Downgrading there would record a narrower claim that is true while
suppressing a wider one that is false — the record would pass as `builder` on evidence that
positively refutes it at `transitive`, and the appraisal would say nothing about why.

A verifier does not record `provenance_depth_verified` at a depth higher than it executed.
Downgrading is how a verifier stays honest when evidence does not resolve; claiming depth it did
not run is what the field exists to prevent. This last rule cannot be expressed in JSON Schema:
the record is byte-identical whether the verifier walked the chain or merely says it did. The
conformance vectors in
[`examples/build-provenance-depth/`](https://github.com/agentrust-io/trace-spec/tree/main/examples/build-provenance-depth)
hold it instead, against a verifier's own output, and encode the split above vector by vector.

Records that omit `provenance_depth` are treated as `surface`. This keeps every record issued
before this field existed valid and correctly interpreted.

### Profile floors

Deployment profiles select the minimum acceptable verified depth:

| Profile | Floor |
|---|---|
| Default, SLSA L0 to L1 | `surface` |
| SLSA L2 and above | `builder` |
| FIPS-aligned, EU AI Act Annex IV high-risk, HIPAA | `transitive` |
| cMCP reference profile | `builder`, with `transitive` recommended where ecosystem coverage permits |

A verifier whose configured floor is not met by `provenance_depth_verified` sets
`appraisal.status` to `contraindicated`.

### Why depth is recorded rather than assumed

A SLSA attestation produced by a trusted builder is signature-valid even when a maintainer's CI
token has been stolen and used to publish a poisoned build input. Surface verification accepts
that record. Transitive verification rejects it, because the poisoned input's publisher
attestation does not chain back to the legitimate maintainer. Without a recorded depth, two
conformant verifiers reach opposite conclusions on the same record and neither says why, which
is the federation gap [section 1](../spec/trace-v0.2.md) names.

That case is a failure and not a downgrade, and it is the sharpest reason the two are kept
apart. The poisoned input's attestation resolved: the verifier holds it and can see the issuer
is outside the trusted set. A verifier permitted to call that "transitive coverage unavailable"
would record `builder`, accept, and report exactly what a verifier that never looked reports —
which would make the depth field cover for the attack it was added to expose.

### `transitive` is a floor on effort, not a comparable claim

Until evidence resolution is standardized, two verifiers can both honestly record `transitive`
over different material sets. Nothing above specifies which inputs must be enumerated or where a
publisher attestation must be looked up, so the value states how far a verifier walked, not what
ground it covered. `builder` has no such gap, because `provenance_uri` names its own evidence.
A consumer comparing `transitive` across verifiers is therefore comparing effort; it does not
license the inference that the same dependencies were checked. Specifying a transitive coverage
URI is left to a follow-up.

[Build provenance depth](build-provenance-depth.md) is the informative companion to this section:
it states what each depth does not assure.

## CLI verification

```bash
Expand Down Expand Up @@ -209,7 +290,7 @@ Verification proves *what happened during the recorded session* under the stated
- Prove the agent's internal reasoning was sound
- Prove the policy was correctly authored for the intent
- Prove tool call *contents* (only the hash of the transcript is in v0.1)
- Prove how the artifact was built past the point the verifier stops walking; [Build provenance depth](build-provenance-depth.md) states what each stopping point leaves unknown
- Prove how the artifact was built past the depth recorded in `appraisal.provenance_depth_verified`; [Build provenance depth](build-provenance-depth.md) states what each stopping point leaves unknown
- Prove physical completion or functional-safety compliance for externally consequential actions
- Replace ongoing monitoring

Expand Down
16 changes: 11 additions & 5 deletions examples/build-provenance-depth/01-all-depths-accept.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,21 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "accept",
"failures": []
"verified_depth": "transitive",
"failures": [],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,25 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "reject",
"verified_depth": "builder",
"failures": [
"attestation_subject_mismatch"
]
],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"attestation_subject_mismatch"
]
],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,25 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "reject",
"verified_depth": "builder",
"failures": [
"attestation_builder_mismatch"
]
],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"attestation_builder_mismatch"
]
],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "builder-chain-accepts-dependency-unattested",
"description": "Subject and builder both bind, so Builder-chain accepts. One resolved build input has no publisher attestation at all, which is the shape a compromised-dependency build presents: the top-level attestation is honest about a poisoned input.",
"name": "builder-accepts-dependency-unattested",
"description": "Subject and builder both bind, so `builder` accepts. One resolved build input has no publisher attestation at all, which is the shape a compromised-dependency build presents: the top-level attestation is honest about a poisoned input.",
"profile": "trace.build_provenance.depth.v0",
"context": {
"artifact_digest": "sha256:361044e7e99a17fef9f0d4d454286b1c888a28819ad0fb756d05deb0b1a272e8",
Expand Down Expand Up @@ -124,15 +124,21 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"outcome": "reject",
"failures": [
"transitive": {
"outcome": "accept",
"verified_depth": "builder",
"failures": [],
"unresolved": [
"dependency_attestation_missing"
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "builder-chain-accepts-dependency-publisher-untrusted",
"name": "builder-accepts-dependency-publisher-untrusted",
"description": "Every resolved input carries an attestation, so a verifier checking only for presence accepts. One was signed under an OIDC issuer the verifier does not trust, so the input's publisher is unverified rather than verified-and-wrong.",
"profile": "trace.build_provenance.depth.v0",
"context": {
Expand Down Expand Up @@ -31,21 +31,21 @@
},
"resolvedDependencies": [
{
"uri": "pkg:npm/%40example-org/agent-core@1.8.2",
"uri": "pkg:npm/%40example-org/telemetry@0.4.7",
"digest": {
"sha256": "fb6bfb2c7ffab3de12cbac5639014ae049aafb4dbb36390f251c82f63cf2d2cb"
"sha256": "3b6f7d63eeee3e1a792c0fa54e5dbb581ba3a651cfc20672fb6ca2af3fea0fb3"
}
},
{
"uri": "pkg:pypi/requests@2.32.3",
"uri": "pkg:npm/%40example-org/agent-core@1.8.2",
"digest": {
"sha256": "a30964f520316ef09eb682027001f6f142547f350fadaeef8be4bb1b48f6d49c"
"sha256": "fb6bfb2c7ffab3de12cbac5639014ae049aafb4dbb36390f251c82f63cf2d2cb"
}
},
{
"uri": "pkg:npm/%40example-org/telemetry@0.4.7",
"uri": "pkg:pypi/requests@2.32.3",
"digest": {
"sha256": "3b6f7d63eeee3e1a792c0fa54e5dbb581ba3a651cfc20672fb6ca2af3fea0fb3"
"sha256": "a30964f520316ef09eb682027001f6f142547f350fadaeef8be4bb1b48f6d49c"
}
}
]
Expand Down Expand Up @@ -149,17 +149,23 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"dependency_publisher_untrusted"
]
],
"unresolved": []
}
}
}
Loading