product attestor
Snapshots the working directory after the step's command runs, computes a Merkle root over every product file's digest, and emits a single in-toto subject (tree:products) whose digest is the root. The full per-file digest map is not carried in the predicate — that lives in a producer-side sidecar (attestation.tree.json) and is exposed to consumers via separate inclusion-proof attestations on demand.
| Name | product |
|---|---|
| Predicate type | https://aflock.ai/attestations/product/v0.3 |
| Lifecycle | product |
| Default binary? | Yes |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | Not auto-detected — attach explicitly with -a. |
The facts in this box are generated from the cilock binary's own catalog (cilock tools list). Do not hand-edit — run npm run gen:catalog.
What it captures
The v0.3 predicate is small and fixed-size. The schema:
| JSON field | Type | Source |
|---|---|---|
merkleRoot | string (<algo>:<hex>) | The Merkle root over the sorted product list, computed via RFC 6962 §2.1. Hex-encoded for byte hashes; gitoid URI form for gitoid hashes. |
treeSize | integer | Number of files that contributed to the root (after include/exclude glob filtering). |
hashAlgorithm | string | Name of the hash algorithm. Default sha256. Matches the algorithm cilock used to build the tree. |
construction | string | Always RFC6962 for v0.3. Future hash constructions would extend this field. |
The DSSE statement's subject array carries one entry:
"subject": [
{
"name": "tree:products",
"digest": { "sha256": "<merkleRoot>" }
}
]
That is the entire surface area of the predicate. The full per-file list — every path and every digest — does not appear here. It lives in the <outfile>.product.tree.json sidecar cilock run writes next to the signed envelope (and a parallel <outfile>.material.tree.json for the material attestor).
Why v0.3 looks like this
v0.2 carried the full per-file digest map (map[path]Product) inside the predicate. For source-only projects that was fine — a go build produces a handful of files. For package installations (pip install litellm, npm install next, cargo build) the map ballooned to tens of thousands of entries, which:
- Inflated DSSE envelope size to multi-megabyte territory.
- Required Archivista to materialize a separate per-file index server-side to answer the question "which build contains file digest X" without re-decoding every predicate.
- Forced consumers to download and parse the full predicate even when they only cared about one file.
v0.3 fixes all three by moving per-file claims into separate inclusion-proof attestations. The product attestation says "this tree exists and these are its properties"; an inclusion-proof attestation says "and this specific file is in it." Together they verify per-file claims. See issue #135 for the full rationale.
How the product set is captured
This attestor commits a Merkle root over a set of output files — but which files count as products, and where their digests come from, is decided by the active capture mode, not by the attestor itself:
- Directory walk (default, and the only mode without
--trace): files created or changed in--workingdirduring the command window become products. cilock uses mtime so a byte-identical rebuild still registers as a product. - Syscall trace (
--trace, Linux): cilock observes which files the step and its child processes wrote — including outputs written outside the working directory. The trace backend isptrace+seccomp(always available) oreBPFwhere the kernel supports it;CILOCK_TRACE_MODE=autoprobes eBPF and falls back to ptrace. - fanotify (
--hardening standard/strict): hashes product content atFAN_CLOSE_WRITEand anchors the set to files that still exist at process exit.
--capture-mode auto (the default) uses trace events when --trace is on and the directory walk otherwise. See how cilock captures files for the full comparison and a selection guide.
When to use
It always fires — there is no --enable-attestor product toggle and no opt-out. Shape the input file set with --attestor-product-include-glob and --attestor-product-exclude-glob. These globs apply to forward-slash-normalized paths.
Flags
| Flag | Default | Effect |
|---|---|---|
--attestor-product-include-glob | * | Files matching this gobwas/glob pattern are included as leaves in the Merkle tree. |
--attestor-product-exclude-glob | "" | Files matching this pattern are skipped; evaluated before include. |
Both globs match against the forward-slash-normalized relative path inside the working directory. On Windows, write patterns with / even when the on-disk separator is \.
Subject behavior
Subjects() returns exactly one entry, tree:products. The digest is the Merkle root computed via the following two-step leaf encoding:
Sort products by forward-slash-normalized path (lexically).
For each (path, file-digest) pair:
leafPreHash = sha256(path-bytes || 0x00 || file-digest-bytes-raw32)
// 32-byte pre-hash; path-bytes is the UTF-8 forward-slash form,
// file-digest-bytes-raw32 is the RAW 32-byte sha256 (NOT the hex string).
Pass the leafPreHash list into a merkle tree built per RFC 6962 §2.1.
The wrapper applies its own 0x00 leaf-domain prefix and 0x01 interior prefix,
so the actual leaf the tree commits to is:
H(0x00 || leafPreHash) = H(0x00 || sha256(path || 0x00 || file-digest))
The 0x00 inside leafPreHash is the path/digest separator (preventing collisions like ("foo", digestA) vs ("fooX", digestA')). The 0x00 the merkle wrapper prepends is the RFC 6962 leaf-domain prefix (preventing the CVE-2017-12842 64-byte interior-node-as-leaf attack). They are distinct constants serving distinct purposes — see merkle trees.
Paths are normalized with inclusionproof.NormalizePath (strings.ReplaceAll(p, "\\", "/"), not filepath.ToSlash) so a Windows-recorded root re-hashes identically on Linux. The same helper is the single canonical normalizer for both product and material — drift between the two would silently break verification.
If zero files survive the globs, the predicate still carries a root: the RFC 6962 empty-tree root (sha256("")). The tree:products subject is always present so verifiers can refuse a missing root rather than treating "empty" as "absent."
Output shape
The full DSSE statement for a v0.3 product attestation:
{
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [
{
"name": "tree:products",
"digest": { "sha256": "9c6f...d3a1" }
}
],
"predicateType": "https://aflock.ai/attestations/product/v0.3",
"predicate": {
"merkleRoot": "sha256:9c6f...d3a1",
"treeSize": 30142,
"hashAlgorithm": "sha256",
"construction": "RFC6962"
}
}
The predicate is fixed-size regardless of how many files were in the working directory. A 30,000-file build produces the same predicate length as a 3-file build.
Sidecar tree
cilock run writes two sidecar files adjacent to the signed envelope whenever the product and material attestors build trees:
<outfile>.product.tree.json— the product tree<outfile>.material.tree.json— the material tree
Both share the same on-disk schema: rookery.inclusion-proof.sidecar/v0.1, defined in plugins/attestors/inclusion-proof. The body carries source (either "product" or "material"), the Merkle root, the tree size, the pinned hash algorithm and construction constants, and the sorted list of (path, fileDigest) pairs — exactly the inputs needed for cilock prove to reconstruct the tree and emit inclusion proofs.
The sidecar is not signed. It is not evidence. Treat it as a build cache: regenerate by re-running the build, or discard once the inclusion proofs you need have been emitted. Do not ship it to downstream consumers — they only need the signed inclusion-proof attestations.
Path convention: for --outfile attestation.json the sidecars are attestation.product.tree.json and attestation.material.tree.json. If --outfile is empty (stdout), no sidecars are written.
See prove files in a build for the producer flow.
Composition with inclusion-proof attestations
The product attestation alone does not let a consumer verify a per-file claim — it says "the tree exists and its root is X" but does not expose any specific leaf. The consumer-facing piece is the inclusion-proof attestation:
- The product attestation says: "this tree exists; its root is X; size is N; built with sha256/RFC6962."
- An inclusion-proof attestation for a specific file says: "this file's digest is leaf
iin the tree with root X; here is the audit path."
A verifier with both attestations confirms (a) the audit path reconstructs the claimed root, (b) the claimed root matches the product attestation's subject digest, and (c) the leaf hash matches the file the verifier was asked about. See verify a specific file for the full check sequence.
Gotchas
- Globs operate on forward-slash-normalized paths. Write
dist/**/*even on Windows. - MIME detection is gone from v0.3. v0.2 emitted per-file MIME types so downstream attestors (SBOM, VEX, SLSA) could find SBOM files by MIME. v0.3 does not carry per-file metadata. Downstream attestors that previously walked
ctx.Products()for MIME-typed files continue to work — they read from the attestation context's product map, which is populated by the same workdir-snapshot code, not from the v0.3 predicate. - Include/exclude globs affect the tree, not just the subject. Excluded files are not leaves and do not contribute to the root. (v0.2 globs affected only the subject; v0.3 globs affect the full tree.)
- Anything in
ctx.Materials()at step start is not a product. Files produced by an earlier stage become materials in later stages and stop appearing as products. - Empty product set still emits a tree subject. Per the v0.3 spec the predicate ALWAYS carries a root — an empty workdir produces the RFC 6962 empty-tree root (
sha256("")). Verifiers must refuse a missing-root predicate, not treat "empty" as "absent."
CLI example
Builtin. cilock always runs this — classifies files written during the wrapped command and emits the Merkle root.
cilock run --step my-step \
--signer-file-key-path key.pem --outfile attestation.json --workingdir build/ \
-- make build
The signed attestation.json and the unsigned attestation.tree.json sidecar both land in the working directory after the run completes.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Merkle trees — the underlying construction
- Prove files in a build — producer-side flow
- Verify a specific file — consumer-side flow
- Issue #135 — design rationale
Status: this documents the historical v0.2 wire format, not emitted by any current cilock build — new attestations use the latest version (select it from the version dropdown above). The v0.2 decoder remains registered so
cilock verifycan read pre-cutover attestations.
Snapshots the working directory after the step's command runs, records each new or changed file with its hash and detected MIME type, and emits a single tree:products subject over the included product set.
| Name | product-v0.2 |
|---|---|
| Predicate type | https://aflock.ai/attestations/product/v0.2 |
| Lifecycle | product |
| Default binary? | No |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | Not auto-detected — attach explicitly with -a. |
The facts in this box are generated from the cilock binary's own catalog (cilock tools list). Do not hand-edit — run npm run gen:catalog.
v0.2 wire format
v0.2 retained the same per-file predicate body as v0.1 — a flat map[string]Product (per-file mime_type + digest) — but collapsed the in-toto Statement.Subject array into a single tree:products subject whose digest was a hand-rolled hash chain over (name || 0x00 || file-digest || 0x00) per file. v0.2 fixed the placeholder explosion but produced a tree the verifier could not prove individual file inclusion against without re-walking the build — which is why v0.3 supersedes it.
Because the predicate body is identical between v0.1 and v0.2, both versions share a single LegacyDecoder (the constructor is parameterized by predicate URI). The decoder is registered against the v0.2 predicate URI https://aflock.ai/attestations/product/v0.2 under the name product-v0.2, and emits file:<path> subjects from the decoded map so the policy engine's subject-graph BFS can match historical v0.2 attestations by per-file digest. Attest() returns errLegacyDecodeOnly — the decoder cannot produce. To emit a new product attestation, use the latest version.
Why the cutover
v0.2 still carried the full per-file digest map inside the signed predicate (10+ MB envelopes for large installs) and, despite the single subject, gave the verifier no way to prove a specific file was in the tree without re-walking the build.
The latest version publishes a single tree:products subject (the RFC 6962 Merkle root) and moves the per-file claims into separate inclusion-proof attestations emitted on demand by cilock prove. See rookery#135 for the full rationale.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Upstream: witness/product.md
Status: this documents the historical v0.1 wire format, not emitted by any current cilock build — new attestations use the latest version (select it from the version dropdown above). The v0.1 decoder remains registered so
cilock verifycan read pre-cutover attestations.
Snapshots the working directory after the step's command runs and records each new or changed file with its hash and detected MIME type.
| Name | product-v0.1 |
|---|---|
| Predicate type | https://aflock.ai/attestations/product/v0.1 |
| Lifecycle | product |
| Default binary? | No |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | Not auto-detected — attach explicitly with -a. |
The facts in this box are generated from the cilock binary's own catalog (cilock tools list). Do not hand-edit — run npm run gen:catalog.
v0.1 wire format
The v0.1 predicate body is a flat map[string]Product keyed by file path relative to the working directory. Each entry carries the json-tagged fields of attestation.Product:
mime_type— MIME type fromgabriel-vasile/mimetypedetection on the file contents.digest—cryptoutil.DigestSetof the file (sha256, plus any other algorithms the producing run configured).
Example:
{
"dist/cilock": {
"mime_type": "application/x-mach-binary",
"digest": {
"sha256": "…",
"gitoid:sha1": "gitoid:blob:sha1:…"
}
},
"dist/sbom.spdx.json": {
"mime_type": "application/spdx+json",
"digest": { "sha256": "…" }
}
}
The DSSE statement's subject array carries one file:<path> entry per product. The product-v0.1 LegacyDecoder (in plugins/attestors/product/legacy.go) reads this shape and exposes Subjects() for policy BFS lookup; BackRefs() returns empty (per-file BackRefs on historical attestations are an explosion risk in the verify-time graph walk). Attest() returns errLegacyDecodeOnly — the decoder cannot produce.
Why the cutover
v0.1's per-file subject array caused two real problems Archivista had to work around:
- Placeholder explosion. A
pip install litellmproduces ~3,200 files, each emitting its ownfile:<path>subject. Multi-file builds blew through MySQL's 65,535 prepared-statement parameter cap. - 10+ MB DSSE envelopes. Every file's path and digest landed in the signed predicate body.
The latest version publishes a single tree:products subject (the RFC 6962 Merkle root) and moves the per-file claims into separate inclusion-proof attestations emitted on demand by cilock prove. See rookery#135 for the full rationale.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Upstream: witness/product.md