Skip to the content.

D1 · fail-closed audit

This is the coverage ledger for fak’s failure posture: every guard, adjudication rung, and hook on the kernel’s decision path, enumerated with the mode it takes when the check itself breaks — not when it merely returns a verdict.

The distinction the ledger exists to make precise:

The audit’s rule is that a fail-open entry is not automatically a defect, but it is never allowed to be silent: it must appear in this ledger with a stated rationale, or the CI gate below reds the build.

The architectural rule this audit found

fak does not have one uniform posture, and claiming so would be the overclaim this ledger is meant to prevent. The real rule, which held everywhere it was checked:

Enforcement fails closed. Observation fails open.

Every rung that decides whether a call runs denies on error. Every surface that watches, records, or advises allows on error, so that a bug in the telemetry path can never wedge a live fleet. Both halves are deliberate; the ledger’s job is to keep the boundary between them explicit and testable, so a rung cannot drift from the first category into the second unnoticed.

This is the inverse of a fail-open-by-default posture, where a broken check silently downgrades to no check at all on the enforcement path itself.


THEOREM 1 — the adjudication core denies on every indecision path

THEOREM. kernel.Fold has no path that yields anything but a conclusive verdict or Deny. Absence of policy, universal deferral, and residual indeterminacy each fold to ReasonDefaultDeny.

PROOF. Fold (internal/kernel/kernel.go:189) has exactly three terminal branches that are not a conclusive rung verdict, and all three deny:

Conflicting conclusive verdicts resolve by abi.FoldRank, most-restrictive-wins (kernel.go:210-216), so an unrecognized verdict kind cannot outrank Deny.

Two adjacent defaults carry the same posture. The adjudicator’s Posture zero value is PostureFailClosed (internal/adjudicator/decide.go:135, iota), so an unset or zero-valued posture is the strict one rather than the permissive one. The require-witness gate stays closed with ReasonUnwitnessed when every resolver abstains or none is registered (internal/kernel/kernel.go:237) — an uncorroborated claim is not a corroborated one.

WITNESS.

go test ./internal/kernel/ ./internal/adjudicator/ -count=1 -timeout 120s \
  -run 'TestFoldResidualIndeterminateFailsClosed|TestFoldDefaultDenyEmptyPolicy|TestNeverAdmits'

VERDICT. PROVEN by construction and by the pinning tests above.


THEOREM 2 — an unclassified repo-guard reason denies

THEOREM. repoguard.DefaultSeverity returns SeverityDeny for any reason not present in its posture table.

PROOF. The default posture map is deliberately permissive for the reasons it names — OUT_OF_TREE_WRITE and LIVE_MONITOR_OUTPUT_READ resolve to SeverityRecord (silent journal row, allow) and the hint-bearing rungs to SeverityWarn (internal/repoguard/severity.go:77-85). The fallthrough, however, is strict: an unknown reason returns SeverityDeny (severity.go:91-95), so a refusal-class reason added later denies until it is explicitly softened in the table. Severity.String likewise renders an unknown value as "deny" (severity.go:53-55). The permissiveness is enumerated; it is not the default.

WITNESS.

go test ./internal/repoguard/ -count=1 -timeout 120s

VERDICT. PROVEN.


The commit-boundary gate ledger

Every gate registered by hooks.PreCommitGates() (internal/hooks/hooks.go:76), with its default enforcement when it detects a violation. block refuses the commit; warn is advisory and prints without refusing. Each gate additionally honors a ModeEnv to soften it and a one-shot EscapeEnv to skip it once — deliberate operator overrides, not fail-open paths.

The Fail mode column is the mode taken when the gate’s own Check returns an error. It is fail-open for every entry, structurally: cmd/fak/hooks.go:355 skips a gate whose check errored and runs the rest. Since #5299 that skip is reported rather than silent — the gate is named on stderr and carried in the --json output as skipped_gates/skipped_count — but it is still a skip, and the fail-open posture recorded below is unchanged. See FINDING 1.

The table below is machine-read by the CI gate; the fenced region is parsed and cross-checked against the live registry in code.

Entry Default enforcement Fail mode Note
PUBLIC_LEAK block fail-open staged content matched against redact-needles
SECRET_SHAPE block fail-open credential-shaped literals in the staged diff
DOC_PLACEMENT block fail-open keeps stray docs out of the repo root
BROKEN_LINK block fail-open relative markdown links must resolve
FILE_ADMISSION block fail-open private-only, junk, or oversized paths refused
INDEX_SYNC block fail-open INDEX.md / llms.txt reciprocal-orphan check
CONCEPT_ADMISSION block fail-open a new concept needs its glossary entry
CONCEPT_FRESHNESS block fail-open concept docs must not go stale against code
PROVENANCE_LABEL block fail-open witnessed / observed / modeled labelling
HARDWARE_TELL block fail-open no local-hardware blocker as a terminal answer
BARE_COMMIT_SWEEP warn fail-open advisory by design (#3615); closes the raw-git bypass
E2E_OVER_MOCKS warn fail-open advisory by design (#2901); asks for a witnessed run
TRUST_WIDENING warn fail-open advisory; flags a widened trust boundary
PRIOR_ART warn fail-open advisory; prints the SOTA reference to cite
UNTIERED_LEAF warn fail-open advisory by design (#3614); staged twin of TIER_DECLARED
GOFMT warn fail-open advisory; commit-boundary twin of make ci gofmt-check
DUPLICATION warn fail-open advisory; in-process twin of fak dup guard –staged

The observation surfaces

These are enumerated for completeness. All fail open, and all are correct to do so: none of them decides whether a tool call runs.

Surface Fail mode Why it is not a finding
cmd/repoguard PreToolUse hook fail-open Documented at cmd/repoguard/main.go:12; always exits 0 and signals a deny through the JSON decision payload, so a guard bug cannot wedge a fleet. See FINDING 2.
Stop hook (cmd/fak/guard_stophook.go) fail-open Allows the stop when the gate cannot decide, and labels the refusal string fail-open: so the choice is visible in the ledger rather than silent.
Pre-compact hook (cmd/fak/guard_precompact.go) fail-open Context maintenance, not admission.
Tool-process hooks (cmd/fak/guard_toolproc_hooks.go:22) fail-open Instrumentation; always exits 0 by design.
Kernel observers (internal/kernel) fail-open A panicking observer is recovered per-observer and cannot change the verdict — a tested contract (internal/kernel/emit_failopen_test.go).
Guard launcher config path (cmd/fak/guard.go) fail-closed Setup, flag, and config errors exit 2; the session does not launch.

Findings

Per this issue’s scope, findings are recorded, not fixed here. Fixes that landed later under their own issues are noted inline, so this stays a live ledger rather than a snapshot that quietly goes stale.

FINDING 1 — a pre-commit gate that errors is skipped silently. RESOLVED (#5299). At the time of this audit cmd/fak/hooks.go continued past any gate whose Check returned an error. The skip is deliberate and contractual (internal/hooks/hooks.go:36-39, ErrCouldNotRun: “fail-open, never a block”), and the rationale is sound — a broken checker must not wedge every commit on a shared trunk. The defect was not the fail-open, it was the silence: no stderr line, no counter, and no distinction in the exit code between “17 gates ran clean” and “PUBLIC_LEAK errored and the other 16 ran clean”. An operator could not tell a green commit from a degraded one. The smallest fix is observability, not enforcement: emit the skipped gate’s name and count it.

Resolution. #5299 did that and no more. cmd/fak/hooks.go:355-369 names the gate on stderr and appends it to a ledger surfaced in --json as skipped_gates/skipped_count. Both keys are emitted on a clean run too, as [] and 0, so a consumer can never read an absent key as “nothing was skipped”. The fail-open posture is unchanged and is now pinned by test — every witness asserts the exit code stays 0, and a mutant that flips could-not-run to blocking reds them. One deliberate restraint: the gate’s error VALUE is never rendered, only a fixed classification literal, because PUBLIC_LEAK and SECRET_SHAPE scan the staged diff and their error text can carry the very material they matched. Witness:

go test ./cmd/fak/ -count=1 -run 'PreCommit|EnabledGateNames'

FINDING 2 — the PreToolUse hook swallows malformed input as “allow”. cmd/repoguard/main.go fails open on any internal error, including a payload unmarshal failure. A guard bug and a malformed payload are different failure classes: the first argues for fail-open, the second is attacker-influenceable input. Worth separating, though the hook is a heuristic floor and not a sandbox by its own documentation.

Both findings were recorded here rather than fixed, per #2865’s stated out-of-scope boundary. FINDING 1 has since been fixed under its own issue (#5299) and is marked resolved above; FINDING 2 remains open.

The CI gate

internal/hooks/failclosed_ledger_test.go parses the fenced table above and enforces four properties:

  1. Bidirectional coverage — every gate in hooks.PreCommitGates() has exactly one ledger row, and every ledger row names a live gate. A new guard landing without a ledger row reds the build, which is what makes this ledger an enumeration rather than a snapshot.
  2. Declared enforcement matches code — each row’s Default enforcement must equal the gate’s real DefaultMode. Quietly downgrading a block gate to warn reds the build.
  3. Closed fail-mode vocabulary — each row’s Fail mode must be exactly fail-closed or fail-open. An undeclared or invented mode reds the build.
  4. Fails closed on a parse of nothing — zero parsed rows is a failure, not a pass, so moving or renaming this file cannot read as green.

WITNESS.

go test ./internal/hooks/ -count=1 -timeout 120s -run TestFailClosedLedger

Assumptions to recheck