Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

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.sh

The 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

valuebehavior
`` (unset) / autoProbe 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 / offDisabled (recorded as coverage gap fanotify-disabled). --hardening off sets this.
1 / onREQUIRE 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

valuebehavior
`` (unset) / 0 / offDisabled.
autoProbe FS at startup; opportunistically seal each product on close.
1 / onREQUIRE 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:

countermeaning
bpfOpenatDropsBPF ringbuf dropped openat events
bpfReadtapDropsBPF ringbuf dropped read-tap chunks
fanotifyTimeoutsHandler took longer than 2s; kernel default-allowed
fanotifyQueueOverflowsKernel emitted FAN_Q_OVERFLOW
fanotifyCapHitPer-trace 200K digest cap reached
unhashedOpensFiles observed open but couldn't be hashed
fallbackHashFailuresAggregate hash failures
fsverityFailuresKernel 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:

sourcetrust level
fanotify-open-timeKernel-synchronous hash; race-tight at open time
openat-path-hashHashed via /proc/<pid>/fd at openat time; small race window
bpf-streamingAccumulated via sys_read kretprobe; what the tracee actually saw
fanotify-onlyLook up in summary.fanotifyOnlyDigests
`` (empty)No digest captured (mmap-read with no prior hash; zero-copy syscall)

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:

  1. 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.
  2. Zero-copy syscalls — copy_file_range, splice, sendfile transfer bytes kernel-side without firing fanotify or read-tap. The SyscallEvent records source + destination paths but no content digest.
  3. memfd_create / O_PATH opens — no path to mark; not captured.
  4. 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:

modehash completenessoverhead vs baseline
BPF-only86-99% (with ~1% wrong-digest cases)baseline
BPF + fanotify100%~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.