product attestor
Captures products after the command and emits one in-toto subject, tree:products, with the RFC 6962 content root. The name remains product. The predicate type remains https://aflock.ai/attestations/product/v0.3.
Generic library constructors retain inline leaves. The compact profile keeps a small product predicate inline and detaches larger inventories. It also detaches inventories when legacy leaves cannot preserve distinct paths. Capture and the before-command baseline remain unchanged.
| 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 CI/lock binary's own catalog (cilock tools list). Do not hand-edit — run npm run gen:catalog.
What it captures
Every representation carries these commitment fields:
| JSON field | Type | Source |
|---|---|---|
merkleRoot | lowercase hex string | The RFC 6962 root over distinct product content digests. |
treeSize | integer | Number of distinct content digests after filtering, not the full path count. |
hashAlgorithm | string | Always sha256. |
construction | string | Always RFC6962. |
The DSSE statement's subject array carries one entry:
"subject": [
{
"name": "tree:products",
"digest": { "sha256": "<merkleRoot>" }
}
]
The predicate also carries inline leaves or a new inventory reference. These shapes cannot coexist. Legacy manifest and manifestUploaded fields are forbidden alongside a new reference.
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 reduces subject cardinality to one tree. Compact inventories also separate large per-file payloads from the signed parent. The root construction remains unchanged.
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
The CLI attaches it by default. Its always_on detector declares the output contract, including the inventory companion, without adding an automatic detection gate. It does not re-enable a default removed with --no-default-attestor. 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:
Deduplicate products by content digest and sort by digest bytes.
For each distinct file digest:
leafPreHash = sha256(file-digest-bytes-raw32)
// 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(file-digest))
The 0x00 prefix separates leaf hashes from interior hashes. Paths and MIME/kind metadata are not part of the content root. Inline signatures or exact-byte inventory references authenticate that metadata.
Legacy leaves use inclusionproof.NormalizePath. Full inventories preserve the captured path bytes instead. Distinct paths with equal contents remain distinct inventory entries but contribute only one content leaf.
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
Commitment fields in a v0.3 product statement (representation fields omitted from this illustration):
{
"_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": "9c6f...d3a1",
"treeSize": 30142,
"hashAlgorithm": "sha256",
"construction": "RFC6962"
}
}
The detached parent grows with its counters, not with its leaf list. Inline predicates grow with their leaves.
Inline leaves
Generic constructors retain inline (path, fileDigest, leafHash) triples with optional mimeType and kind. The compact profile also uses this encoding when every path survives and the serialized predicate fits its budget.
Compact inventories
product.WithCompactInventory(inlineBytes) sets the maximum serialized inline predicate size. Equality stays inline. Zero detaches every nonempty captured content set. The platform distribution uses 131072 bytes.
Equal content at different paths forces detachment even within the budget. Path normalization that loses original path bytes also forces detachment. Captured empty sets retain the existing empty-tree encoding and have no inventory reference.
The signed reference has schema https://aflock.ai/attestations/file-inventory/v0.1, kind product, and state detached. It carries fileCount, the exact predicate byte count, and the raw SHA-256 digest. It contains no retrieval URL or upload claim.
captureMode records walk, trace, or unknown. The corresponding scopes are working-directory, trace-provider, and unspecified. These labels do not prove isolation, atomic capture, or hermeticity.
The payload has schema, kind, and path-sorted entries. Each entry preserves the captured path, fileDigest, and available mimeType/kind metadata. Paths are opaque metadata, never filesystem instructions. Witness-only paths without content digests remain outside the content commitment, as in the legacy producer.
The limits are 64 MiB of predicate bytes and one million entries. The capture safety limit remains separate. Consumers reject unknown fields, duplicate or case-aliased keys, invalid digests, invalid paths, duplicate paths, and inconsistent counts.
The companion predicate uses the same inventory schema URI. Its subject is inventory:product, with the raw SHA-256 digest of its exact predicate bytes. It never inherits the parent commit subject. Integrity comes from the signed parent reference, not the companion signer or storage location.
Local retention does not authorize upload. The CLI controls storage and explicit upload consent separately. Hydration verifies the reference and reconstructs both the content root and treeSize. It restores all paths and metadata without changing the compact serialized parent.
Per-file verification
The product attestation's inline leaves array exposes every (path, fileDigest, leafHash) triple (with mimeType, so a policy can also require that an output IS a Mach-O / ELF / PE binary rather than only that its path is present), so per-file claims are verified directly from the product attestation:
- Find the leaf whose
fileDigestequals the file digest being verified. - Verify that the leaf's
leafHashequalssha256(fileDigest-bytes-raw32)(the canonicalinclusionproof.LeafHashencoder). - Fold the leaf hash through the tree's RFC 6962 structure and confirm the result equals the attestation's
tree:productssubject digest (the Merkle root).
For detached inventories, consumers first verify the exact bytes against the signed reference. Missing or corrupt required details are not empty products. A matching content root alone cannot authenticate paths or metadata.
Gotchas
- Globs operate on forward-slash-normalized paths. Write
dist/**/*even on Windows. - MIME and kind are metadata. Full inventories bind them by exact predicate digest. Inline leaves bind them through the parent signature.
- 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.)
- The material baseline remains active. Unchanged baseline files are not products. Existing mtime rules still capture byte-identical rebuilds.
- 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 parent carries the root and either inline leaves or a compact inventory reference, according to the distribution profile.
See also
- Inclusion-proof attestor — the standalone proof primitive
- Merkle trees — the underlying construction
- 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 CI/lock 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 inlines the per-file leaves in the signed predicate, so every file is verifiable from the product attestation alone. 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 CI/lock 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 inlines the per-file leaves in the signed predicate, so every file is verifiable from the product attestation alone. See rookery#135 for the full rationale.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Upstream: witness/product.md