I audited my own AI agent guardrails. Three of them weren't doing anything.
I have a script whose entire job is to find rules that have no enforcement behind them. It had never been wired up. Three guards were silently dead — and the fourth finding turned out to be my own unverified claim.
I have a script whose entire job is to find rules that have no enforcement behind them.
It had never been wired up. When I finally ran it, it failed immediately.
That is the short version. Here is the long one, because the details are where the useful part lives.
The setup
Over about six months I built an enforcement layer around Claude Code. Not prompts — code. Fifty-five shell and Python hooks that sit in front of and behind tool calls: block git add ., route destructive commands through a capability gateway that requires human confirmation, refuse edits to files the agent hasn't read, detect when the agent is going in circles, hash-chain every tool invocation into an append-only evidence log, redact secrets before they reach output.
If you've read any of the many good posts on Claude Code hooks, you know the thesis they all land on: CLAUDE.md is advisory, hooks are enforcement. Guidance is probabilistic. A correctly wired, fail-closed PreToolUse hook is deterministic. Stop asking the model nicely and start making the system say no.
I agree with that thesis. I built my whole setup on it.
Most of what I read ends at the same place: install the hooks, and now you have deterministic enforcement. Some do cover silent failure modes in individual hooks. What I did not find was anyone treating the enforcement layer itself as something that decays and needs auditing.
Which leaves the question I had to answer for myself.
Six months later, how do you know it's still running?
Three failures, three different deaths
I went looking. Here is what I found, in the order I found it.
1. A corrupt JSON file killed a detector for four days
One hook tracks whether the agent is making progress — repeated identical calls, repeated failures on the same command, edit churn on the same file. It writes counters to a progress.json alongside the evidence vault.
That file was corrupt. Specifically: a complete, valid JSON document, followed by 130 characters of garbage — the tail of a longer document that a concurrent write had partially overwritten.
The consequence chain:
jqstreams. It parsed the valid leading document, wrote the transformed result to a temp file, then hit the garbage and exited non-zero.- The write was
jq ... > "$TMP" && mv "$TMP" "$TRACKER". Non-zero exit meant the&&never fired. - So: temp file left behind. Tracker never updated. Every single time.
The tracker's mtime was frozen four days back. The detector had been dead the entire time, and nothing said so — a dead detector and a detector that finds nothing produce identical output: silence.
Meanwhile the orphaned temp files had been accumulating at roughly one per tool call. When I cleaned them up: 2,844 files, 136 MB. They were still being created while I worked; I deleted them, and eight more appeared during the same session.
The part that stings: the counter schema this hook uses was migrated and marked closed in my backlog on a specific date, with a passing test as evidence. The oldest orphaned temp file is from that same date. The tests passed. The state file in actual use never migrated. The file already existed, and the migration path only initialized the new schema when the file was absent.
2. A rule with nothing to check
My multi-agent council protocol has a rule I'd consider load-bearing: any premise marked [ASSUMED] that more than two sub-tasks depend on must be verified before the first agent is dispatched.
It had never once fired.
Not because anyone was ignoring it. Because the canonical spec requires a ## VERIFIED PREMISES table, and across seven real council briefings, not one contained that section. They used three different conventions for marking assumptions, none of them the one the rule operates on.
The rule had no parseable target. For 33 days it was a sentence in a document that no code could act on. It wasn't being violated — it was inert.
The fix wasn't to write the rule more forcefully. It was to give it something structural to bite on: a typed premises array with id, status, evidence, and which questions depend on it. Now the dispatcher computes the dependency count itself and refuses to dispatch. I tested it by reconstructing a real prior briefing: it correctly blocked the premise four questions depended on, and passed the ones with one and two.
3. The detector that detects this, undetected
And then the one that made me sit back.
I have a governance-lint.sh. It scans my rule files for soft language, for rules lacking a declared enforcement mechanism, for reference material misfiled as policy. It is, precisely, a tool for finding governance that isn't enforced.
It appeared zero times in my hook configuration.
I ran it manually:
Violations: 4 Warnings: 6 Core governance lines: 530
LINT FAILED — 4 violation(s) must be fixed
Four of my rule files declared no enforcement mechanism at all. The detector had been sitting on disk, correct and functional, for however long — reporting nothing, because nothing ever called it.
The fourth finding, which was wrong
I had a fourth. I want to tell you about it because it is the most useful thing in this post.
One of my guards routes destructive commands to a confirmation gateway. Its header comment said, in the author's own words, that its exit code "displays a message but does not block."
I read that, concluded the gate had never blocked anything, wrote it up, put it in a commit message, pushed it to GitHub, and recorded it in my agent's memory system.
Then, while drafting this post, I went to verify it.
Exit code 2 in a PreToolUse hook blocks the tool call. It's in the documentation, plainly. The gate had been working the entire time.
Worse — I had direct evidence in my own files. My handoff notes from the previous session contain a line in a table of environment gotchas: "rsync blocked → capability gateway." My own rsync had been stopped by that exact guard, and I'd written it down.
The comment was wrong about its own mechanism. The mechanism was fine. There was a real defect — the messages went to stdout, while the block reason is read from stderr, so the block landed but the reason may not have reached the model cleanly. My change was still an improvement. Just not for the reason I gave.
So: three failures, not four. I've since pushed a correction commit, and superseded the memory record that carried the false claim.
Here's why this matters more than the three real findings.
I was writing about systems that claim enforcement they don't have. And in the middle of it, I made a confident claim I hadn't verified, and shipped it to three places. The evidence that would have corrected me — the official docs, and a line in my own notes — was in reach the entire time. I just didn't look.
Auditing your governance and being wrong about your governance are the same activity performed with different care.
The one I should have been worried about all along
Going to the documentation for that correction turned up something worse than anything I had found by auditing.
Only exit code 2 blocks. Exit 1 — the conventional Unix "something went wrong" — is a non-blocking hook error: it gets logged, and the tool call proceeds. A guard that dies of a syntax error, an unset variable under set -u, a missing binary, or a timeout does not fail closed. It fails open, quietly, and the only trace is a log line nobody reads.
Every hook you have written is one bad edit away from becoming a no-op that still looks installed. If a guard matters, it needs an error trap that turns any unexpected failure into exit 2 — and you need to have watched it do that at least once. Mine did not have one either.
This is worse than the three failures above, because it requires no corruption, no missing wiring, and no six months. It requires a typo.
Update, same day: I tried to take my own advice and it did not survive review
I wrote the paragraph above, then went to add that error trap to the fifteen blocking guards in my own setup. Before touching them I sent the design to two other models for review. They rejected it, and they were right on three counts.
A trap on EXIT only sees the final exit code. These scripts don't use set -e, so a command that fails mid-script doesn't stop anything — execution continues to the explicit exit 0 at the bottom, and the trap sees a clean zero. The exact failure I was trying to catch, a guard dying halfway through, walks straight past it.
Any later trap ... EXIT in the same script silently replaces it. I verified this: with a cleanup trap defined further down, an unset variable under set -u exits 1 instead of 2. The protection is gone and nothing says so. One of my fifteen already has its own trap.
Not every guard should fail closed. Three of mine are reminder-type — they check whether you looked at a diff, whether a commit message is in the right language, how many times you've retried. Their source already reads mkdir -p "$STATE_DIR" 2>/dev/null || exit 0: a deliberate choice to stay out of the way when their own scaffolding breaks. Forcing those closed means a broken jq blocks every shell command you have — including the ones you'd use to fix jq.
So the advice above is directionally right and materially incomplete. The honest version: a fail-closed trap is one narrow layer, it is defeated by ordinary shell patterns, and it does not answer the question you actually care about.
That question is still "does this guard fire?" — and the only thing that answers it is triggering it. A black-box test per guard: feed it an input that must be blocked, assert it blocks. Not inspection. Not exit codes. Not a linter, including mine.
Which is what the sentence before this section already said: you need to have watched it do that at least once. I nearly skipped my own qualifier. What stopped me was asking someone else before shipping rather than after.
What I actually think now
The community consensus is that hooks beat prompts because hooks are deterministic. That's true and I'm not walking it back.
But there's a second half nobody says out loud:
A hook is not enforcement. It is a claim about enforcement.
Prompts usually fail where you can see it — you watch the model do the wrong thing. Hook failures do not have to announce themselves, and in all three of my cases they didn't, each in a different way. A dead detector and a clean system look identical from the outside. That's the whole problem. The safety property and the failure mode produce the same observable: nothing happens.
Which means the layer everyone is missing isn't a better hook. It's the thing that checks the hooks are alive:
- Does every declared rule name the mechanism that enforces it — and does that mechanism exist and run?
- Does anything alert when a detector's state file stops advancing?
- Is the code you fixed the code that's executing?
- And when your linter reports zero problems, is that because there are none, or because it never ran?
I don't think I have this solved. I have one lint, now finally wired to run whenever I touch a governance file, and it still reports one violation I haven't decided how to fix. That's a starting position, not an answer.
What changed
- Repaired and migrated the corrupt tracker. The detector is live; the leak is at zero.
- Converted the inert
[ASSUMED]rule into a structural check that refuses dispatch, with tests. - Wired the governance lint to run on edits to governance files — and only then. I deliberately did not put it on session start: a warning that appears every single time is a warning you learn to skip, which is how these rules died in the first place.
- Added a tamper-evidence hash chain to my memory system's audit log, modeled on the one already running in my tool-call vault (10,954 entries and counting). Plus a
--verify-chaincommand, because a chain nobody can check is decoration. - Fixed four of the rule files to declare their enforcement mechanism — and marked, explicitly, the parts still not covered. A clean
Enforcement: hookon a file whose hook covers half its clauses is exactly the kind of comfortable lie that started all of this.
The tool
The lint from the opening is now its own repository: governance-lint — a couple hundred lines of bash, no third-party dependencies, no install. Point it at your ~/.claude and read what it says about you.
curl -sO https://raw.githubusercontent.com/MakiDevelop/governance-lint/main/governance-lint.sh
bash governance-lint.sh ~/.claude
It reports which of your rules declare an enforcement mechanism. It cannot tell you whether that mechanism actually fires — that's the harder half, and I don't have a general answer to it. But "this rule names nothing that enforces it" is already a useful thing to learn about your own setup.
The memory system with the hash-chained audit log is also open source: agent-memory-hall (Apache-2.0, MCP-native). It handles session continuity for coding agents: not "where do I store memories" but "did the agent actually do the thing it said it would do next time."
The rest of the guard layer isn't packaged. If enough people want it, that changes.
If you run an enforcement layer around a coding agent: pick one guard, right now, and prove to yourself it fires. Not that it exists — that it fires. I'd genuinely like to know how many of you find what I found.