A checker I had written to catch formatting mistakes in customer-facing messages had been running for weeks and reporting clean. When I finally tested it against the last 60 days of real messages, it turned out that 13 of 25 of them contained a total of 91 violations of the exact rule it was supposed to enforce. It had caught none of them.
The checker was not broken. It looked for **bold**, the Markdown syntax. The messages were going into a system that uses *bold*, the wiki syntax. The rule was written down, the enforcement was real code, and the two had never actually met.
That was one finding in an audit of my own agent setup. Seven findings came out of it, and five were the same shape: a rule existed, and the mechanism that was supposed to enforce it either had never been wired up or did not work the way the documentation claimed. Everything looked green. Nothing was failing loudly. The rules were simply not running.
This is a write-up of those failure modes, because every one of them is silent by design, and because "I wrote it in the config file" is the most common way engineers convince themselves an agent is constrained.
Why is a rule in a config file not a guardrail?
Because the model decides whether to follow it. Instructions in CLAUDE.md, AGENTS.md or any equivalent are delivered as context, and context is something an LLM weighs against everything else in the prompt — not a constraint it is structurally unable to violate.
Anthropic's own documentation is explicit that these files are advisory. The practical consequences show up fast: a long instruction file loses its middle to the lost-in-the-middle effect, a rule that looks irrelevant to the current task gets skipped, and a rule that conflicts with a more recent instruction quietly loses.
There is a hard line between two categories, and most setups blur it:
| Advisory | Deterministic | |
|---|---|---|
| Examples | CLAUDE.md, steering docs, skill instructions | PreToolUse hooks, CI checks, pre-commit, wrapper scripts |
| Who executes it | The model, by choice | The harness, as code |
| Fails how | Silently, by omission | Loudly, with an exit code |
| Right for | Tone, preferences, defaults, taste | Anything that must hold every time |
Neither is better. The mistake is putting a must-hold rule in the advisory column and then believing it is enforced. "Never write a credential in plain text" is not a preference. It belongs in code that can say no.
What does a silent failure actually look like?
It looks like success. That is the whole problem — every one of these returns exit code 0, prints nothing, and leaves no trace in a log you would think to read.
Here are the four that cost me the most time, each verified by breaking it on purpose.
set -e turns a guard into a pass-through
This is the one I would put on a poster. Standard shell advice is to start every script with set -euo pipefail. In a guard script, the -e is actively harmful:
#!/usr/bin/env bash
set -euo pipefail # ← the bug
payload=$(cat)
echo "$payload" | grep -q "FORBIDDEN_PATTERN" # no match → exit 1 → script dies here
echo '{"decision":"block"}' # never runs
grep exits non-zero when it finds nothing. With -e, the script terminates at that line. It never reaches its blocking logic, it returns a non-error status to the harness, and the action it was supposed to stop goes through. The guard fails open, and it fails open specifically on the input it was written to inspect.
The same trap applies to any command that legitimately returns non-zero — jq on malformed input, diff when files differ, test when a condition is false. My guards now start with set -u or set -uo pipefail and handle errors explicitly:
$ for h in ~/.claude/hooks/*.sh; do
printf "%-28s %s\n" "$(basename $h)" "$(grep -m1 '^set ' $h)"
done
dangerous-cmd-guard.sh set -uo pipefail
secret-guard.sh set -u
terminology-guard.sh set -u
flag-detector.sh set -euo pipefail
The last one keeps -e deliberately: it injects context rather than blocking anything, so dying early is safe. The distinction is the point — -e is right for scripts that produce, wrong for scripts that police.
A hook file in the wrong format is inert
Hook configuration that relies on a plugin-scoped variable only resolves while that plugin is installed. Uninstall the plugin and the file stays on disk, looking exactly as correct as it did before, while nothing it declares ever fires again.
There is no warning for this. The file is valid, the syntax is right, the paths look sane. It simply never runs. The only reliable check is to trigger the hook and confirm it did something.
A field name that shifts between versions
A prompt-submit hook that reads the wrong JSON field gets an empty string, does nothing with it, and exits successfully. In my case the field was prompt_text while the script was reading user_prompt. No error, no output, no hook.
The defensive form costs nothing:
prompt=$(jq -r '.prompt_text // .prompt // .user_prompt // empty')
Discovery rules are not symmetrical
I moved a folder of unused agents into an _archive/ subdirectory, assuming it would take them out of circulation. It did not — agent discovery walks the tree recursively, so they were all still loaded. Skill discovery, in the same setup, looks exactly one level deep and does respect the same move.
Two similar-looking mechanisms, two different traversal rules, one wrong assumption. The archive had to move outside the scanned tree entirely.
How do you find these before they cost you?
Try to break each rule and watch what happens. This sounds obvious and almost nobody does it, because writing the rule feels like completing the task.
The audit that produced these findings was not clever. For each rule I asked one question — what input should this reject? — and then fed it that input. Five of seven rules accepted it.
That question generalises well:
- For a formatting checker: give it a document that violates the rule. Does it exit non-zero?
- For a secret guard: try to write a fake credential. Does it block?
- For a dangerous-command guard: run the dangerous command in a harmless form. Does it stop?
- For a context injector: send the trigger and inspect what actually reached the model.
- For a rule file: check that the tool loads it at all, in that specific tool.
That last one caught the most embarrassing finding of the audit. My rule-precedence document — the file that decides which rule wins when two conflict — was being loaded in exactly one of the three tools I use. In the other two it had never been in context. The document explaining how rules apply was itself not applying.
What does a guardrail test suite look like?
A plain script that exercises every enforcement point and prints one line per check. Mine runs 71 of them and takes a few seconds:
== Kiro hooks
OK kiro-guards.json is valid JSON
OK terminology-guard is wired
OK secret-guard is wired
OK dangerous-cmd-guard is wired
OK flag-detector is wired
OK dangerous-cmd-guard writes its reason to STDERR
== MCP parity
OK aws-pricing is defined in all three tools
OK aws-docs is defined in all three tools
...
-----------------------------
OK 71 tests passed.
Three properties make it worth the effort.
It tests wiring, not just existence. Checking that a hook file exists proves nothing. The tests assert that the hook is registered for the right event, in the right tool, and that its output reaches the place that consumes it.
It covers parity across tools. I use three different agents against the same repository. Most of the audit findings were cases where something was correctly configured in one tool and missing in the others. Parity is invisible until you assert it.
Every new enforcement point adds a test. This is the rule that keeps the suite honest. When a guard is added, a test goes with it — otherwise the next silent failure comes from the same place, and you learn about it the same way you learned about the last one: by accident, weeks later.
Run it from whatever already runs on a schedule. Mine is called by a health check, and the result lands on a dashboard where a red line is visible without me looking for it.
Does this mean instruction files are useless?
No — they are excellent at the thing they are actually for. Tone, defaults, vocabulary, house style, which library to prefer, how to structure a response: these are judgement calls, and an LLM applying judgement to them is exactly right. Trying to enforce taste with a shell script would be worse than useless.
The split I use now is simple. If violating a rule is embarrassing, it goes in the instruction file. If violating it is expensive or irreversible, it goes in code. Credentials, destructive commands, anything that reaches a customer, anything that touches production — those are code.
And when a rule graduates from advisory to enforced, the instruction file stays. The model still needs to know the rule so it does not fight the guard. The guard exists for the times the model forgets anyway.
FAQ
Why does Claude Code ignore CLAUDE.md sometimes?
Because CLAUDE.md is delivered as context, not as a constraint the model is structurally unable to violate. The model weighs it against the rest of the prompt and can judge a rule irrelevant to the current task. Long files make this worse, since content in the middle of a long context gets less attention. For anything that must hold every time, use a hook.
What is the difference between a hook and an instruction file?
An instruction file asks the model to behave a certain way; a hook runs as code at a fixed point in the agent's loop and can block an action regardless of what the model decided. Instructions fail silently by omission. Hooks fail loudly with an exit code, which is why they are the right home for must-hold rules.
Why should a guard script not use set -e?
Because grep, jq, diff and test all return non-zero in perfectly normal situations. With set -e the script exits at that point, before reaching its blocking logic, and the harness sees a script that ended without objecting. The guard fails open exactly when it matters. Use set -u or set -uo pipefail and handle errors explicitly.
How do I know if my hook is actually firing?
Trigger it with input it should reject and confirm the action was blocked. Existence of the file proves nothing, and neither does a clean run — a hook that never fires also produces a clean run. If you cannot observe a block, you have not observed the hook.
How often should guardrails be tested?
On every change to the enforcement layer, and on a schedule regardless. Silent failures do not announce themselves, so the gap between breaking and noticing is bounded only by how often you check. A scheduled run turns that gap from weeks into a day.
Do I need a test suite for two hooks?
Two hooks need two tests, and they can live in a ten-line script. The value is not in the framework, it is in the habit: adding a guard and its test in the same commit. Suites that grow to 71 checks start as three.
What to check in your own setup
Three things, in the order that finds the most problems fastest.
Grep your guard scripts for set -e and decide, for each one, whether early exit means fail-open. That single line is the highest-yield check in this article.
Then take each rule you believe is enforced and feed it the input it should reject. Not a review of the code — an actual run. Count how many accept it.
Then, if you use more than one agent or more than one machine, check parity. Configuration drifts in one direction: things get added where you are working and nowhere else.
The pattern underneath all seven findings was the same, and it is worth stating plainly: writing the rule and enforcing the rule are two different pieces of work, and finishing the first one feels exactly like finishing both. The gap between them is silent, and it stays silent until something you assumed was impossible shows up in production.
If you want a worked example of that gap costing real traffic, the Cloudflare Bot Fight Mode postmortem is the same failure in a different layer: every dashboard green, every page indexed, and search crawlers being served a noindex page for five days.