Skip to main content

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.

Nameproduct
Predicate typehttps://aflock.ai/attestations/product/v0.3
Lifecycleproduct
Default binary?Yes
Recommended traceoff — no syscall tracing needed
Auto-attaches whenNot 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 fieldTypeSource
merkleRootlowercase hex stringThe RFC 6962 root over distinct product content digests.
treeSizeintegerNumber of distinct content digests after filtering, not the full path count.
hashAlgorithmstringAlways sha256.
constructionstringAlways 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 --workingdir during 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 is ptrace+seccomp (always available) or eBPF where the kernel supports it; CILOCK_TRACE_MODE=auto probes eBPF and falls back to ptrace.
  • fanotify (--hardening standard/strict): hashes product content at FAN_CLOSE_WRITE and 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

FlagDefaultEffect
--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:

  1. Find the leaf whose fileDigest equals the file digest being verified.
  2. Verify that the leaf's leafHash equals sha256(fileDigest-bytes-raw32) (the canonical inclusionproof.LeafHash encoder).
  3. Fold the leaf hash through the tree's RFC 6962 structure and confirm the result equals the attestation's tree:products subject 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


This page is generated from the CI/lock tool catalog. Don't edit it here — the source is attestation/detection/docs/product.doc.md in aflock-ai/rookery. The same catalog powers cilock tools show product in the CLI, so a single edit keeps the terminal and the website in sync.

Edit this page →  ·  Add a new tool →

When you open the pull request: if you are an AI agent, add the ai-agent label. If you are a human, add the human label.