High-assurance attestation (zero-drop mode)
For release builds where "the attestation is complete or it doesn't ship," CI/lock supports a zero-drop capture mode that combines kernel-synchronous file hashing (fanotify), opportunistic Merkle-root sealing (fs-verity), and a fail-closed verification gate. This page documents the flag matrix and what each guarantee means.
Quick start (release-grade)
- uses: aflock-ai/[email protected]
env:
CILOCK_FANOTIFY: "1" # require synchronous file capture
CILOCK_FSVERITY: "auto" # opportunistic Merkle seal on products
with:
# --capture-mode trace:ebpf requires the eBPF backend (fails loudly if unavailable);
# --require-zero-drops rejects the attestation if any drops occurred.
cilock-args: "--capture-mode trace:ebpf --require-zero-drops"
attestations: "environment git github product sbom command-run"
command: ./build.shThe build will run measurably slower (a typical 60% overhead on small workloads, less on builds dominated by compile time). In exchange you get:
- Zero silent drops on file content. Every open() under the workspace mount is hashed synchronously by the kernel-blocking fanotify handler.
- Kernel-rooted product digests when fs-verity is available on the filesystem. The kernel refuses to read corrupted blocks downstream.
- Fail-closed verification — if ANY drop / timeout / queue overflow / unhashed open occurred at end-of-trace, the attestation is rejected with a structured error.
- Tracee privilege drop — the build process runs as the invoker's user (via SUDO_UID), not root, even though CI/lock retains the capabilities it needs for kernel observation.
Environment flag matrix
CILOCK_FANOTIFY
| value | behavior |
|---|---|
`` (unset) / auto | Probe for fanotify; activate if the probe succeeds. Otherwise continue without it, print a warning, and record the gap as summary.coverage kind fanotify-unavailable. --hardening standard (the default) sets this. |
0 / off | Disabled (recorded as coverage gap fanotify-disabled). --hardening off sets this. |
1 / on | REQUIRE fanotify. Error if probe fails (e.g., CAP_SYS_ADMIN missing). --hardening strict sets this. |
Capabilities: CAP_SYS_ADMIN required. cilock-action's sudo path provides this automatically on hosted GitHub Actions runners.
Filesystem support: ext4, xfs, btrfs (most production filesystems).
Probes both FAN_MARK_FILESYSTEM and FAN_MARK_MOUNT; one of them
works on every supported fs.
Coverage limits: see Known gaps below.
CILOCK_FSVERITY
| value | behavior |
|---|---|
`` (unset) / 0 / off | Disabled. |
auto | Probe FS at startup; opportunistically seal each product on close. |
1 / on | REQUIRE fs-verity availability. Error if FS doesn't support it. |
Filesystem support: ext4 with the verity feature flag enabled
at mkfs time (rare on hosted CI; common on Android, ChromeOS,
some private k8s clusters). Probe gracefully returns EOPNOTSUPP
otherwise.
--require-zero-drops (CLI flag) / WithRequireZeroDrops() (API)
When set, the attestor returns a ZeroDropsError instead of
emitting the attestation if ANY of the following counters are
non-zero at end-of-trace:
| counter | meaning |
|---|---|
bpfOpenatDrops | BPF ringbuf dropped openat events |
bpfReadtapDrops | BPF ringbuf dropped read-tap chunks |
fanotifyTimeouts | Handler took longer than 2s; kernel default-allowed |
fanotifyQueueOverflows | Kernel emitted FAN_Q_OVERFLOW |
fanotifyCapHit | Per-trace 200K digest cap reached |
unhashedOpens | Files observed open but couldn't be hashed |
fallbackHashFailures | Aggregate hash failures |
fsverityFailures | Kernel ioctl returned error |
PartialReadFallbacks is explicitly NOT counted — partial reads are correct behavior (the openat-time path-hash remains authoritative).
Diagnostic surface
Every trace populates summary.diagnostics with these fields. Use
them in Rego policies, dashboards, or alerts:
{
"summary": {
"diagnostics": {
"fanotifyAvailable": true,
"fanotifyEventsHashed": 2004,
"fanotifyDigestsMerged": 198,
"fanotifyTimeouts": 0,
"fanotifyQueueOverflows": 0,
"fanotifyDigestsCapHit": 0,
"fsVerityAvailable": false,
"fsVerityFilesSealed": 0,
"fsVeritySealFailures": 0,
"ringbufOpenatDrops": 0,
"ringbufReadTapDrops": 0,
"unhashedOpensTotal": 0,
"fallbackHashFailures": 0
},
"fanotifyOnlyDigests": {
"/usr/lib/ld-linux-x86-64.so.2": "ab12cd34..."
}
}
}fanotifyOnlyDigests is the kernel-rooted digest for paths fanotify
hashed where no tracee process recorded an open — represents
BPF-missed events that fanotify still caught.
Each SyscallEvent carries a digestSource field tagging the
provenance per event:
| source | trust level |
|---|---|
fanotify-open-time | Kernel-synchronous hash; race-tight at open time |
openat-path-hash | Hashed via /proc/<pid>/fd at openat time; small race window |
bpf-streaming | Accumulated via sys_read kretprobe; what the tracee actually saw |
fanotify-only | Look up in summary.fanotifyOnlyDigests |
| `` (empty) | No digest captured (mmap-read with no prior hash; zero-copy syscall) |
Recommended policy.rego snippet
A step's rego policy receives the command-run attestation itself as input,
so the trace summary is input.summary (not input.predicate.summary). The
verifier evaluates each module's deny rule and refuses a module that has
none. These snippets are evaluated against real command-run attestations by
cilock/cli/guide_rego_examples_test.go, so a change here that
stops them firing fails that test.
package cilock.fanotify
deny[msg] {
not input.summary.diagnostics.fanotifyAvailable
msg := "release-grade attestation requires fanotify (CILOCK_FANOTIFY=1); the trace does not record it as active"
}
deny[msg] {
timeouts := object.get(input.summary.diagnostics, "fanotifyTimeouts", 0)
timeouts > 0
msg := sprintf("fanotify handler timeouts > 0 (got %d): degraded attestation", [timeouts])
}
deny[msg] {
drops := object.get(input.summary.diagnostics, "ringbufReadTapDrops", 0)
drops > 0
msg := sprintf("BPF read-tap drops > 0 (got %d)", [drops])
}Read each field from input inside the negation, as above. Binding
diagnostics := input.summary.diagnostics first and then testing
not diagnostics.fanotifyAvailable fails OPEN: when diagnostics is absent
the binding is undefined, the rule body stops there, and nothing is denied.
Counters the attestor omits when they are zero are read with object.get
and a default: the verifier refuses a policy whose deny reads a field the
attestation does not carry, because such a deny can never fire.
Requiring a complete trace: summary.coverage
Every traced command-run predicate carries summary.coverage. It holds the
tracer that ran (ebpf, ptrace+seccomp, or sandbox-exec+oslog on macOS),
a complete boolean that is always written out, and one gaps[] entry for
each known blind spot, each with a stable kind. The ptrace fallback that
unprivileged containers get, and the macOS tracer, are never complete, and
they say why. A predicate from an older cilock has no coverage, so the rule
below refuses it as well:
package cilock.trace_complete
deny[msg] {
not input.summary.coverage.complete
msg := "trace is incomplete or states no coverage"
}To accept specific known limits, allow the named gap kinds and deny every
other kind. Denying unknown kinds means a gap kind added later is refused
until someone reviews it. Iterating gaps finds nothing to deny when
coverage is absent or states no gaps, so those cases need rules of their
own. object.get supplies the optional fields, because a message built from
an undefined field is itself undefined and would silently drop the denial:
package cilock.trace_gaps
accepted_gaps := {"syscalls-untraced", "fanotify-unavailable"}
deny[msg] {
not input.summary.coverage
msg := "trace states no coverage (untraced, or produced by an older cilock)"
}
deny[msg] {
input.summary.coverage.complete != true
count(object.get(input.summary.coverage, "gaps", [])) == 0
msg := "trace is incomplete but names no gaps"
}
deny[msg] {
gap := object.get(input.summary.coverage, "gaps", [])[_]
kind := object.get(gap, "kind", "")
not accepted_gaps[kind]
msg := sprintf("trace gap %q: %s", [kind, object.get(gap, "detail", "")])
}Known gaps
These are documented in the trace metadata; verifier policy decides whether to accept attestations with them:
- mmap-read content — when a tracee opens a file then reads via page faults (JVM classpath, ld.so loader, memory-mapped DBs), fanotify hashes at open time. If the file mutates between open and the page fault, the digest is stale. The SyscallEvent for mmap surfaces the file path so verifiers can policy on it.
- Zero-copy syscalls —
copy_file_range,splice,sendfiletransfer bytes kernel-side without firing fanotify or read-tap. The SyscallEvent records source + destination paths but no content digest. - memfd_create / O_PATH opens — no path to mark; not captured.
- Files outside the workspace mount — fanotify marks one mount; system libraries on the rootfs come from BPF read-tap with its drop characteristics.
Cost profile
Measured on a synthetic 200-file burst on Ubuntu 24.04 GHA runner:
| mode | hash completeness | overhead vs baseline |
|---|---|---|
| BPF-only | 86-99% (with ~1% wrong-digest cases) | baseline |
| BPF + fanotify | 100% | ~60% on small workloads; less on compile-heavy |
The overhead amortizes on builds dominated by compute time. For a
typical Go monorepo build (~10s baseline), expect ~16s with
fanotify. For a kernel make -j$(nproc) build (~10 min baseline),
expect ~12 min — the per-open overhead is dwarfed by compile time.
When NOT to enable fanotify
- Builds with extreme file open rates where the synchronous block overhead is unacceptable (e.g., Bazel's per-action sandbox setup that opens 100K+ files per action).
- Filesystems that reject FAN_MARK_FILESYSTEM AND FAN_MARK_MOUNT (rare; FUSE-mounted volumes).
- Environments without CAP_SYS_ADMIN (most non-sudo container workloads).
In these cases use the BPF-only path (default) and accept the
~1-4% drop rate. Surface summary.diagnostics.ringbufReadTapDrops
in your CI dashboard so you know when it's degrading.
Reference generated from the product documentation. Match commands and support details to your installed release.