material attestor
Snapshots the working directory before the step's command runs, computes a Merkle root over every input file's digest, and emits a single in-toto subject (tree:materials) whose digest is the root. The full per-file digest map is not carried in the predicate — it lives in a producer-side sidecar (<outfile>.material.tree.json) and is exposed to consumers via separate inclusion-proof attestations on demand.
| Name | material |
|---|---|
| Predicate type | https://aflock.ai/attestations/material/v0.3 |
| Lifecycle | material |
| 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 material predicate is small and fixed-size. The schema:
| JSON field | Type | Source |
|---|---|---|
merkleRoot | string (hex) | The Merkle root over the sorted material list, computed via RFC 6962 §2.1. |
treeSize | integer | Number of files that contributed to the root. |
hashAlgorithm | string | Always sha256 for v0.3. |
construction | string | Always RFC6962 for v0.3. |
The DSSE statement's subject array carries one entry:
"subject": [
{
"name": "tree:materials",
"digest": { "sha256": "<merkleRoot>" }
}
]
That is the entire surface area of the predicate. The full per-file list lives in the <outfile>.material.tree.json sidecar cilock run writes adjacent to the signed envelope.
Why v0.3 looks like this
v0.1 emitted a flat map[path]DigestSet directly as the predicate body, with one file:<path> subject per material. For source trees the cardinality was fine — a Go module produces a few dozen materials. For container builds (COPY . /app over a JS project's node_modules) the per-file subject count blew through Archivista's placeholder budget and inflated the signed envelope to multi-megabyte territory.
v0.3 publishes a single subject (the Merkle root) and moves per-file claims into separate inclusion-proof attestations.
How the material set is captured
This attestor commits a Merkle root over a set of input files — but which files, 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): every regular file under--workingdirat step start is hashed. A portable before-snapshot of the inputs. - Syscall trace (
--trace, Linux): cilock observes the process'sopenatcalls so materials reflect the inputs actually read — including files 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): supplies the content hash atFAN_OPEN_PERMtime (each inode hashed once), race-tight against an input that's modified later in the same build.
--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. Its output is the canonical "what existed on disk when the step started" record — consumed by policy to verify that a step's inputs match a known prior product (chained materials → products across steps), and used as subjectOf evidence for SLSA provenance.
Flags
The material attestor itself registers no flags. Its behavior is controlled by the global run flags it reads from AttestationContext:
| Flag | Effect on material |
|---|---|
--workingdir / -d | Root of the walk |
--hashes | Hash algorithms applied to every file (default sha256) — v0.3 commits only the sha256 leaf to the tree |
--dirhash-glob | Glob patterns of directories to collapse into a single dirhash digest (excluded from the v0.3 leaf set because the dirhash key isn't a raw file content sha256) |
Subject behavior
Subjects() returns exactly one entry, tree:materials. The digest is the Merkle root computed via:
Walk the working directory per attestation/file.RecordArtifacts (regular files only,
symlinks bounded to the workingdir, dirhash globs honoured).
Filter to entries that have a raw sha256 digest (dirhash/gitoid entries are skipped).
Sort by inclusionproof.NormalizePath(path) (lexically).
For each (path, file-digest) pair:
leafPreHash = sha256(path-bytes || 0x00 || file-digest-bytes-raw32)
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 leaf encoder is inclusionproof.LeafHash — the same canonical function the product attestor uses. Any drift between the two would mean a file recorded as a product in one step could not be matched against the same file recorded as a material in the next step. There is exactly one implementation; both attestors call it.
If the working directory has no regular files with a sha256 digest, Subjects() returns an empty map. (Unlike product, the material attestor does not emit an empty-tree root: an empty material set is treated as absent, since "the workingdir was empty before this step" is a less interesting claim than "the step produced nothing.")
Output shape
The full DSSE statement for a v0.3 material attestation:
{
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [
{
"name": "tree:materials",
"digest": { "sha256": "4f1e...aa72" }
}
],
"predicateType": "https://aflock.ai/attestations/material/v0.3",
"predicate": {
"merkleRoot": "4f1e...aa72",
"treeSize": 218,
"hashAlgorithm": "sha256",
"construction": "RFC6962"
}
}
The predicate is fixed-size regardless of how many files were in the working directory.
Sidecar tree
cilock run writes <outfile>.material.tree.json adjacent to the signed envelope. The schema is rookery.inclusion-proof.sidecar/v0.1 — the same format as the product sidecar, distinguished by the source: "material" field at the top of the document. cilock prove --sidecar <outfile>.material.tree.json reconstructs the tree and emits per-file inclusion proofs.
The sidecar is not signed. Treat it as a build cache.
Composition with inclusion-proof attestations
The material attestation alone confirms a tree exists with root X. To prove that a specific input file was in the tree, the consumer also needs the inclusion-proof attestation cilock prove emits for that file. The combined check is:
- The inclusion-proof attestation's
treeRootmatches the material attestation'stree:materialssubject digest. - The audit path reconstructs the claimed root.
- The leaf path + digest identify the file the consumer is asking about.
See verify a specific file for the full check sequence.
Gotchas
- The leaf set excludes dirhash and gitoid entries.
--dirhash-globdirectories still appear in the in-memoryMaterials()map (so downstream attestors that walkctx.Materials()continue to see them), but they do not contribute to the Merkle root because the dirhash isn't a raw file sha256. - Symlinks pointing outside
--workingdirare silently dropped, not errored. If you depend on a linked tree being recorded, place it inside the working directory. materialruns before the command. Files created by the step appear only inproduct, never here.- Empty material set → no subject. Verifiers must handle the no-
tree:materialscase (typically: a step that adds no inputs is allowed; the policy gate is elsewhere).
CLI example
Builtin. cilock always runs this — hashes files present in workingdir BEFORE the wrapped command runs.
cilock run --step my-step \
--signer-file-key-path key.pem --outfile attestation.json --workingdir src/ \
-- make build
The signed attestation.json and the unsigned attestation.material.tree.json sidecar both land in the working directory after the run completes.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Product attestor — companion attestor for outputs
- Merkle trees — the underlying construction
- Prove files in a build — producer-side flow
- Verify a specific file — consumer-side flow
Status: this documents the historical v0.1 wire format. It is not emitted by any current cilock build — new attestations always use the latest version (select it from the version dropdown above). The v0.1 decoder remains registered so
cilock verifycan read pre-cutover attestations.
Records a digest of every regular file under the working directory before the step's command runs, establishing the input baseline for the in-toto attestation.
| Name | material-v0.1 |
|---|---|
| Predicate type | https://aflock.ai/attestations/material/v0.1 |
| Lifecycle | material |
| 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.
What it captures
The attestor walks --workingdir and produces a flat map keyed by the path of each file relative to the working directory. The value is a DigestSet — a map of {hash-algorithm} -> {hex digest} computed per file with the algorithms selected by the global --hashes flag (default sha256).
Walk semantics, from attestation/file.RecordArtifacts:
- Only regular files are hashed. Directories, FIFOs, device files, sockets, and other special files are skipped (FIFO skipping is an explicit DoS hardening).
- Symlinks are followed only when their resolved target stays within the working directory boundary (resolved via
filepath.EvalSymlinkson both sides so/var↔/private/varstyle aliases match). Targets outside the boundary are skipped; broken symlinks are skipped; visited targets are de-duplicated. - When a directory matches any
--dirhash-globpattern, the entire subtree is collapsed into a single Go-moduledirhashentry (key gets a trailing path separator) and the walk does not descend further into it. - Hashing is parallelized across
GOMAXPROCSworkers.
Flags
The material attestor itself registers no flags. Its behavior is controlled by the global run flags it reads from AttestationContext:
| Flag | Effect on material |
|---|---|
--workingdir / -d | Root of the walk |
--hashes | Hash algorithms applied to every file (repeatable; default sha256) |
--dirhash-glob | Glob patterns of directories to collapse into a single dirhash digest |
Output shape
Attestor.MarshalJSON emits the materials map directly — there is no wrapping object, so the predicate body is a flat map of path → digest set:
{
"cmd/main.go": {
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"go.mod": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"vendor/": {
"sha256:dirhash": "h1:abc123..."
}
}
The Schema() method reflects map[string]cryptoutil.DigestSet{} for exactly this reason — a struct wrapper would misrepresent the wire format.
Gotchas
- Symlinks pointing outside
--workingdirare silently dropped, not errored. If you depend on a linked tree being recorded, place it inside the working directory. --dirhash-globmatches directory paths only and short-circuits descent (filepath.SkipDir); individual files inside a matched directory will not appear as separate entries.materialruns before the command, so files created by the step appear only inproduct, never here.
See also
- Inclusion-proof attestor — the per-file claim primitive
- Upstream: witness/material.md