A Warning That Doesn't Block Is a Bug

The commit output was right there, printing the guard’s own death notice: a dependency it needed was missing. Then the commit went through. The pre-commit hook that scans my repositories for credentials and confidential patterns had announced it could not run, and treated “could not run” as “nothing to find.”

Nobody saw that line when it printed, and that is the point: a warning scrolling past in commit output while the commit proceeds is functionally silence. A warning that doesn’t block is a bug in the control. An audit established how far the failure went, and the forensics were more interesting than the scare.

The hook’s wrapper collected its scanning patterns from a helper program. When the helper failed, the wrapper received empty output, and empty output looked exactly like an empty pattern list. No patterns, nothing to check, exit zero. The guard had no way to distinguish “I am broken” from “there is no danger”: the two states produced identical behavior. Fail open by construction.

The second finding was worse. Whether the helper failed depended on which Python interpreter won PATH resolution, and my machine carries three. The daily default had the dependency, so the guard worked in normal sessions, which is exactly why nothing seemed wrong. Sessions that reordered PATH silently got an unprotected hook. A control whose effectiveness depends on interpreter order is a coin flip wearing a uniform.

If you run pre-commit hooks, secret scanners, deploy gates, or any automation whose job is to stop a bad thing, some of this is probably your architecture too. Nearly every hand-rolled wrapper I have inspected, including the snippets people copy between projects, exits clean when its own machinery breaks: a command substitution feeding eval, a warn-and-continue branch, a || true tail. Those are the shapes to grep for. We engineer the happy path of our protective automation and never ask the only question that defines it: what happens when the guard itself fails?

I wrote earlier about why a green test suite is a warning, not a clearance: tests that pass while verifying nothing. This is that essay’s sibling with higher stakes. There, the detector lies; here, the detector is dead and nobody scheduled the funeral. Both belong to the same family of failure: silence read as safety.

Three properties fix this class. Together they form the contract I now hold every protective layer to.

Property one: fail closed

A guard that cannot run must block the guarded action, loudly, with remediation steps. Never wave it through with a note. The asymmetry is decisive: a blocked legitimate commit costs minutes, while one unscanned commit that leaks a credential or a client’s name can cost a reputation.

The implementation requirement is structural, not aspirational: “engine broken” and “nothing to detect” must be distinguishable states. My rebuilt engine emits an explicit success sentinel with its results; the wrapper requires the sentinel, and absence blocks the commit. Empty output now means broken, and broken means stop. The same idea in other stacks: a scanner that must write “0 findings” rather than nothing; a deploy gate that requires a signed verdict artifact, not the absence of an error file.

Fail closed also has a deployment corollary that config-driven guards miss: refuse to run on missing or invalid policy. My engine treats an absent config, an unparsable allowlist entry, or a policy file written for a different engine version as blocking errors. There is a version handshake between engine and policy, and mismatch stops commits rather than guessing. An allowlist that fails to parse and is silently skipped is an allowlist that silently became “allow everything.” I found exactly that shape in my own pre-rebuild engine, where an invalid exclusion pattern would have disabled the exclusion checks instead of stopping the commit.

And the environmental variance goes away at the root, not with better error messages: the rebuilt helper uses only the standard library, so every interpreter on any machine yields byte-identical policy. I proved that by hashing the emitted policy under all three interpreters. If your guard needs pip install to be safe, your guard is optional.

Property two: the guard needs its own doctor

Here is the uncomfortable property of protective automation: its job is to be invisible when healthy, which means its death is invisible too. My hook had been silently weakened for a stretch I could only establish afterward, by audit. Nothing watched the watcher, and the watcher’s silence read as health.

Production code gets monitoring, alerting, health checks. Your guards are production code with an adversary and no pager. So every protective layer I run now ships a doctor: one command that verifies the whole chain and answers, on demand, the question nobody was asking. Is the protection actually alive right now?

State the general rule and it sounds obvious, which is why it is worth stating: quiet is evidence of safety only when something independent is asserting that the check ran. Without that, quiet is just quiet, and it looks identical whether you are safe or unprotected. The doctor is what converts an absence of alarms into a positive statement.

Mine checks that the config parses and matches the engine’s version contract. It validates every configured pattern against both regex dialects in the chain, because patterns are checked by one engine at authoring time and executed by another inside the deployed hook, and a pattern that parses in the first but errors in the second becomes a silent skip: another fail-open in disguise. It confirms the hook is actually wired into git’s hook path. And it verifies the installed engine matches its source, because a hot-fixed guard that drifted from version control is itself an unmonitored fork of your protection.

A doctor nobody runs is the same as no doctor, so it runs at the moments that matter: install time (the installer fails loudly if the result is unhealthy), the start of each working day, and new-machine bootstrap, before the first commit, because migration days are maximum-leak-surface days and were previously the most likely days for the guard to be silently absent. Silent decay now gets at most one working day to stay silent.

The generalization is a question you can ask of every protective control in your stack today: if this broke right now, would anything block? Would anyone notice? Within what interval? If the answers are no, no, and never, the control is theater.

Property three: a false-alarm budget near zero

The first two properties make the guard reliable. The third keeps the humans from routing around it, and it is the one most guard authors never think about: every false block trains the human to bypass, and every benign warning trains the human to ignore output.

--no-verify exists, and your team knows it exists. The economics are brutal: every false block makes the reflexive bypass of the next block, the real one, more likely. This is not a discipline problem. It is a design problem. The guard’s false-positive rate is a security property, exactly as load-bearing as its detection rate.

Three design consequences follow.

Legitimate work needs a sanctioned path. While rebuilding this system, my own hook blocked my own commits twice; the work was about sensitive patterns, so the diffs matched them. The correct response existed: a narrowly-scoped, version-controlled exclusion mechanism, visible in the config’s history, auditable later. The bypass flag never got typed. If the only relief valve you offer is --no-verify, you have made total bypass the ergonomic option.

Precision is safety, and it cuts both ways. My guard’s path exclusions originally matched anywhere in a path, which meant a decoy directory with the right name could exempt anything an adversarial process wanted exempted, and an over-broad exclusion could quietly swallow files it was never meant to cover. Anchoring every exclusion to explicit path roots narrowed both the attack and the accident. And one category is excluded from exclusions entirely: credential patterns scan everything, in every repository class, with no exception mechanism at all. Some tiers of your policy should be structurally incapable of being turned off.

Even the guard’s noise budget matters. The rebuilt engine’s health check initially printed harmless interpreter warnings on every run. Accurate, benign, and corrosive, because a protective tool that emits routine noise teaches its operator to ignore its output. The fix shipped the same night as everything else. Silence when healthy, loud when not, and nothing in between.

Live-fire your guards in both directions

Green checkmarks tell you the guard ran; they do not tell you it guards. I did not trust this system until I had staged real commits in real repositories and watched it behave in both directions. A credential pattern in a scanned path: blocked. The same pattern in a path covered by an exclusion: still blocked, because credentials ignore exclusions. A legitimate sensitive-adjacent edit through the sanctioned exclusion: passed. A confidential-pattern match in a public repository: blocked, with only the offending line flagged. A panel of AI reviewers I had prompted to attack the engine’s earlier version had already found five fail-open holes; every one now exists as a regression test, and the live fire confirmed the fixes where they actually run.

The checklist, portable to any protective layer:

  1. Break the guard deliberately (remove its dependency, corrupt its config) and confirm the guarded action blocks.
  2. Confirm “broken” and “clean” produce structurally different outputs, not the same silence.
  3. Feed it a real true-positive and watch it block. Not a unit test. The deployed guard, the real channel.
  4. Feed it a true-positive through every exclusion and allowlist path; the highest-value bypasses live there.
  5. Run its doctor; then check when the doctor last ran without a human remembering to run it. If the answer is “never automatically,” wire it into a daily ritual.
  6. Count last month’s false alarms honestly. Each one is a withdrawal from the account your guard’s next real alarm draws on.

AI agents raise the stakes on all of this, in both directions. Agents multiply the volume of changes flowing through your guards: more commits, more drafts, more surface. But they also change the economics of compliance. My agents never type the bypass flag, they route legitimate blocks through the sanctioned exclusion path, and they run the doctor as part of the daily ritual without getting bored. Guards built to the contract get more dependable under agent workflows, not less; the discipline that human muscle memory erodes is exactly the discipline agents execute indefinitely.

Protective automation is production code with an adversary. Fail closed, carry your own doctor, and spend your false alarms like the currency they are.

Also published on synthesiscoding.org