The spine of the graph
CI/lock's verifier matches attestations to policy steps by subject digest. It starts from the seed digests the caller supplies (whatever was passed to cilock verify -s sha256:… or -f path/to/artifact) and finds every attestation whose subject set contains one of them.
This page is about which digests the verifier is allowed to search for. The short answer: the caller's seeds, plus the producer seeds a policy's artifactsFrom edges prove. Nothing else widens the search.
Seed-only reach
Verification searches exactly the caller's seed digests:
// attestation/policy/policy.go
// SEED-ONLY REACH. Verification searches exactly the caller's seed
// digests. A collection's relationship edges (its BackRefs: commithash,
// parenthash, pipeline URL, tree roots) are never turned into new search
// seeds: evidence a policy step relies on must be matched by a seed the
// caller supplied. There is no search depth; WithSearchDepth is accepted
// and ignored.A Collection is the group of attestors a single cilock run step emits together. Attestors still record BackRefs (the Git attestor its commit and parent hashes, the Product and Material attestors their tree roots), and the policy engine keeps them on the collection, but it never turns them into new seeds. WithSearchDepth is kept for API compatibility and has no effect.
Earlier versions did walk BackRefs breadth-first to a configured depth. That walk was removed because a BackRef is a relationship claim, not a content relation: the git attestor's parenthash let a parent commit's clean secrets scan witness its child commit. If a step's evidence lives under a different digest (the commit, for example), pass that digest as a second seed: -s sha1:<commit>.
Chained reach through artifactsFrom
A policy's artifactsFrom edge is different in kind from a BackRef. It names the step that must have produced the bytes a consumer step consumed, and the engine proves that from two signed predicates: the consumer's material leaves and the producer's product leaves must agree on path and digest.
So the engine adds one kind of reach (attestation/policy/chain_reach.go). When a collection passes the gate of a step that declares artifactsFrom: [S], its signed, root-checked material leaves become search seeds for step S, and only for S. Every collection those seeds reach is then gated and artifact-checked exactly like a caller-seeded one. A collection that no passing consumer accepts as its producer is pruned.
A consumer step whose own gate reads its producer through attestationsFrom cannot pass before the producer is found. For such a step, collections that verify and are signed by one of its functionaries (and, under a commit binding, belong to the evaluated commit) also name producer seeds, before their gate runs. They are used for nothing else: they never pass a step on that hop, the producer they reach is gated in full, and the consumer is gated in full on the next hop with the producer in context. Chained seeds never become caller subjects, so they never appear in a VSA's subject list. The empty-file digest never seeds, because every step that touches an empty file records it.
This is how a deploy gate holding only an image digest can reach the build step that produced the image, without the caller knowing the build's tree root.
What the consumer step must record for an image
Chained reach links two steps by a file, never by an image. The image digest finds the push step's collection (the oci attestor names the pushed reference's registry digest as a subject), and from there the engine can reach build only through a file that push recorded as a material and that a build collection recorded as a product, at the same path with the same sha256. Two consequences:
- An image the push step built itself links on its own. If
pushrunsdocker buildfrom a context that holds the WAR, the WAR is one of its materials and the build that produced it is found. - An image built in an earlier step does not. When
buildproduces the image (for example Maven's docker plugin) and a later job only pushes it, the push step's workspace holds no build output, and nothing in the push collection names a filebuildproduced. CI/lock does not look inside image layers, and two collections naming the same image digest are not a link. The verify then fails withstep build referenced in artifactsFrom has no passed collections.
For the second shape, give the push step a build product and record it as a material under the path build recorded it at. Keep the file outside the push step's working directory, so the material walk does not also record it under a second, untracked path:
# The push job: the WAR the image contains, copied from the build job to $RUNNER_TEMP.
cilock run --step push \
--attestations oci \
--attestor-material-bind "target/app.war=$RUNNER_TEMP/app.war" \
-- docker push registry.example.com/app:1.4.2"push": {
"artifactsFrom": ["build"],
"requiredArtifacts": ["target/app.war"],
"allowedUntracked": ["<the rest of the push workspace>"]
}--attestor-material-bind RECORDED_PATH=FILE hashes FILE before the command runs and records it under RECORDED_PATH (see the material attestor). The digest is the file's own; only the key is chosen. requiredArtifacts pins the edge to the WAR, so a shared system file cannot satisfy it on its own. Under enforced hardening (the cilock verify default) every other push material must be a build product or match allowedUntracked; keep that list as narrow as the workspace allows. Whether the WAR is really the one inside the pushed image is a separate question the push step's Rego can answer, for example by comparing the image ID the build printed with the registry digest the push recorded.
How a file digest finds a product attestation
A v0.3 product attestation's only subject is its Merkle tree root (tree:products). A per-file digest is a leaf inside that tree, never a subject, so a subject search for a file digest cannot match the product collection directly.
cilock verify -f <file> bridges this before the policy runs (cilock/cli/inclusion_bridge.go). It looks through the loaded collections for a product or material tree that contains the file's digest, using, in order:
- Inline leaves. The product or material predicate carries its per-file leaves. The bridge rebuilds the tree from them and requires it to fold back to the signed root.
- Single-leaf reconstruct. When a predicate carries no leaves and the tree has exactly one leaf, the root is the leaf hash.
- Inclusion-proof envelope. For selective disclosure, a standalone inclusion-proof attestation whose RFC 6962 audit path folds to the signed root.
On success the bridge adds the tree root as an extra seed, and the product collection matches. The root and tree size always come from the collection's signed predicate, never from the inclusion-proof envelope. Adding a root as a seed grants no trust on its own: the matched collection still has to pass the step's functionary and signature checks.
Under the default compact evidence profile, product leaves stay inline up to a 128 KiB budget; a larger product set is written to a private file inventory that the predicate references by digest. Material leaves are left out of the envelope; --material-manifest keeps them in a local inventory, and --upload-inventories uploads it. Chained reach reads material leaves, so a policy that relies on artifactsFrom needs them retained (the compact-chain profile turns both flags on by default). A file that only appears in an inventory the verifier was not given can be bridged with an inclusion-proof envelope instead.
Why subjects and BackRefs stay separate
A subject is what an attestation is about. A BackRef is a relationship the attestation claims. If every subject or every BackRef widened the search, a malicious or accidental attestation that listed an unrelated digest would pull the verifier into evidence the user did not ask about: a denial-of-service vector at best, an evidence-injection vector at worst. Before the walk was removed, the empty product tree alone joined 2,050 product back-references into one clique and grew a depth-3 verify from 9 to 7,141 envelopes.
Restricting reach to caller seeds and to artifactsFrom edges keeps every search anchored in something the caller asked for or the policy proved.
What we considered and rejected: the v0.2-style approach
The alternative was to keep the v0.2 design: a flat per-file digest map in the product predicate as the only per-file representation, with the verifier reading digests directly out of that map and no Merkle root or inline-leaves discipline behind it.
That approach was rejected for three reasons. v0.3's leaves avoid all three: they fold to a single signed root, the subject stays the tree root, and every per-file claim is verifiable against the signed envelope:
-
Unbounded predicate size with no escape hatch. An
npm install-scale tree carries ~30k entries × ~80 bytes ≈ 2.4 MB of predicate per envelope, plaintext. Multiply by every build per day. The v0.2 map had no way to opt out; v0.3 inlines a compact leaf array (path + digest + pre-hash per entry) and, under the compact profile, moves a large set to an inventory bound by digest. -
No per-file provability. With the v0.2 flat map, the verifier had no Merkle path to walk: every per-file claim required re-decoding the full predicate. v0.3's leaves carry the RFC 6962 audit path preimage directly, so a single leaf check is O(log n).
-
No clean path from file to product. The v0.2 product attestor's subject is
tree:products(the Merkle root). Per-file digests lived only in the predicate, so a subject search could not find a v0.2 product attestation by file digest at all; bridging that gap needed a separate file-digest index materialized server-side. v0.3's leaves keep the tree-root subject and let the verifier bridge a file to its tree from the signed attestation, with the inclusion-proof attestation as the fallback when leaves are not available.
v0.3 is the only producer. For verification, a single LegacyDecoder covers both pre-cutover predicate URIs: product-v0.1 and product-v0.2 share the same flat map[string]Product predicate body (they only differ in the in-toto Statement.Subject array v0.2 collapsed to a single tree-root subject), so one decoder parameterized by predicate URI reads both. Existing v0.1 and v0.2 envelopes flow through cilock verify with no operator action, but the LegacyDecoder refuses Attest(). The full rationale is in issue #135 on the rookery repo.
Cross-references
- Policy verification: what the search is in service of
- Inclusion proofs: the proof primitive the bridge uses
- Product attestor v0.3: the attestation whose tree root the bridge adds as a seed
- Inclusion-proof attestor: the attestation type whose subject is the file digest
Reference generated from the product documentation. Match commands and support details to your installed release.