TL;DR
Prompt rules are suggestions; hooks are law. After my autonomous Claude Code agent "helpfully" ran a destructive git command that my carefully written instructions told it never to run, I moved my guardrails out of the prompt and into Claude Code hooks β small deterministic scripts that fire before and after every tool call. This post covers the three hooks that now guard every session I run, with copy-pasteable configs. π‘οΈ
The Problem
I run Claude Code (v2.x) as a mostly unattended coding agent. It reads a task, edits files, runs tests, commits. For months, my safety strategy was a growing list of rules in the project instructions file:
Never force-push. Never delete branches. Never run migrations against anything that isn't local. Always run the formatter before committing.
And for months, it mostly worked. That word "mostly" is the entire problem.
One evening the agent got into a state where a rebase had gone sideways. It reasoned β quite logically, from its point of view β that the cleanest way out was git checkout -- . followed by resetting the branch to origin. It wiped about two hours of its own uncommitted work. Nothing irreplaceable, but it stung, because my instructions explicitly said not to discard working changes without committing a checkpoint first.
Here's the uncomfortable truth I had to accept:
An LLM follows instructions statistically. A guardrail needs to hold 100% of the time.
When the model is calm and the task is simple, prompt rules hold. When the context window is full of error output and the agent is three failed attempts deep into fixing something, that "never do X" paragraph from 40,000 tokens ago is competing with a very loud, very recent "X would fix this right now." Sometimes the recent signal wins. You cannot prompt-engineer your way to a hard guarantee.
What I actually needed was a mechanism that:
- Runs outside the model, so it can't be reasoned around
- Fires on every tool call, not just when the model remembers
- Fails loudly, so the agent knows it was blocked and why
That's exactly what Claude Code hooks are.
How I Solved It
Hooks are shell commands that Claude Code executes at fixed lifecycle points. The two I lean on hardest are PreToolUse (runs before a tool call, and can block it) and PostToolUse (runs after, great for cleanup and enforcement). There's also Stop, which fires when the agent thinks it's finished.
The mental model:
flowchart LR
A[Agent decides to run a tool] --> B{PreToolUse hook}
B -- exit 0 --> C[Tool executes]
B -- exit 2 --> D[Blocked - reason fed back to agent]
C --> E{PostToolUse hook}
E --> F[Formatting, checks, logging]
D --> A
The key detail: when a PreToolUse hook exits with code 2, the tool call is blocked and the hook's stderr is shown to the agent. The agent doesn't just fail silently β it gets told why it was stopped, and it adapts. It's like a linter for agent behavior.
Here are the three hooks that guard every session I run.
Hook 1: The command firewall (PreToolUse)
This is the one that would have saved my two hours. It inspects every Bash command before execution and blocks a denylist of destructive patterns.
The config lives in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh" }
]
}
]
}
}
And the guard script itself. Hooks receive the tool input as JSON on stdin, so you parse it with jq and pattern-match:
#!/bin/bash
# .claude/hooks/guard.sh β block destructive commands
cmd=$(jq -r '.tool_input.command // ""')
deny=(
"git push --force"
"git push -f"
"git reset --hard"
"git checkout -- ."
"git clean"
"rm -rf"
"DROP TABLE"
)
for pattern in "${deny[@]}"; do
if [[ "$cmd" == *"$pattern"* ]]; then
echo "BLOCKED: '$pattern' is on the denylist. Commit a checkpoint first, then ask for a safer alternative." >&2
exit 2
fi
done
exit 0
Twenty lines. It has fired 14 times in the last two months. That's 14 moments where a statistical instruction-follower decided a destructive command was a good idea, and a deterministic script said no. Every single one of those would have been a dice roll under the prompt-rules regime. π²
One thing I learned the hard way: give the agent an escape route in the error message. My first version just said "BLOCKED." The agent would retry the same command with slight variations, probing for a way through. Once the message said what to do instead ("commit a checkpoint first"), the agent started actually doing that. The hook isn't just a wall β it's feedback.
Hook 2: The auto-formatter (PostToolUse)
Smaller win, but it removed a whole category of nagging. Instead of telling the agent "always run the formatter after editing," I just... run the formatter after it edits:
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" }
]
}
#!/bin/bash
# .claude/hooks/format.sh β format whatever file was just touched
file=$(jq -r '.tool_input.file_path // ""')
case "$file" in
*.py) ruff format "$file" 2>/dev/null ;;
*.ts|*.tsx|*.js) npx prettier --write "$file" 2>/dev/null ;;
esac
exit 0
This deleted an entire paragraph from my instructions file. Every rule you can move from prose into a hook is a rule the model can no longer forget, and it frees context-window attention for rules that genuinely need judgment.
Hook 3: The exit gate (Stop)
The Stop hook fires when the agent believes it's done. Mine runs the test suite; if tests fail, it exits with code 2 and the agent is sent back to work instead of declaring victory:
#!/bin/bash
# .claude/hooks/done-check.sh β don't let the agent stop with red tests
if ! npm test --silent > /tmp/test-out.log 2>&1; then
echo "Tests are failing. Fix them before finishing. Last lines:" >&2
tail -5 /tmp/test-out.log >&2
exit 2
fi
exit 0
Before this hook, "done" meant "the agent feels done." After it, "done" means "the tests pass." Those are very different quality bars, and the second one doesn't depend on the model's mood. β
β οΈ One caveat: add a bailout condition (a max-retries counter in a temp file works) or a genuinely stuck agent will loop against the gate forever.
Lessons Learned
Prompt rules are policy; hooks are enforcement. You need both, but never confuse them. If violating a rule would cost you real time, data, or money, it belongs in a hook. If it's a style preference, the prompt is fine.
The blocked message is a prompt injection you control. Exit code 2's stderr goes straight into the agent's context at the exact moment it matters β infinitely more salient than a rule buried 40k tokens back. Write it like a helpful senior engineer: what was blocked, why, and what to do instead.
Every hook you add shrinks your instructions file. I cut my project instructions by roughly a third after moving enforcement into hooks. Shorter instructions means the rules that remain actually get followed more reliably. Deterministic code handles the "always/never" stuff better than prose ever will.
Denylist beats allowlist for Bash, allowlist beats denylist for everything else. I tried allowlisting shell commands first. It was misery β agents compose commands in endlessly creative ways, and I spent days approving harmless variations. Denylist the catastrophic patterns, let everything else through, and rely on git for recovery.
Hooks are your audit log for free. Since every hook sees every tool call, a one-line
echo "$(date) $cmd" >> ~/.agent-audit.loggives you a complete record of what your agent actually did β which is how I know the firewall fired 14 times, instead of vaguely feeling like "it helps."
What's Next
Two things I'm experimenting with:
-
Risk-scored gating β instead of a binary denylist, a hook that scores commands (touching
.env? network calls? package installs?) and only blocks above a threshold, with different thresholds for interactive vs. unattended sessions. -
Session-scoped budgets in hooks β a
PreToolUsecounter that caps how many times the agent can run the test suite per session, nudging it to think instead of brute-forcing the exit gate.
The bigger theme: as I hand more autonomy to agents, the interesting engineering keeps shifting from what I ask the model to do to what the harness around the model permits. Hooks are the cheapest possible entry point into that mindset.
Wrap-up
If you run Claude Code today, here's your 15-minute version: create .claude/hooks/guard.sh with the denylist above, wire it into settings.json, and forget about it. The first time it blocks something, you'll feel the same mix of relief and mild horror I did. π
If you found this useful, follow me here on Dev.to β I write regularly about running autonomous coding agents in the real world: state persistence, self-verification, parallel agents, and all the ways this stuff breaks at 2am. And if you haven't tried Claude Code yet, the hooks system alone is worth the install.
What's in your denylist? Drop it in the comments β I'm collecting war stories. π¬
Top comments (0)