leek.io leek.io
contact
#ai #claude-code #tooling #tutorial

Guardrail Hooks That Teach the Fix, Not Just Say No

CJ
Chris Jones · @leek
Nov 5, 2026 · 7 min read · 1,322 words
Guardrail Hooks That Teach the Fix, Not Just Say No

My global CLAUDE.md has a line telling the agent that zsh aborts on any unquoted word starting with =. So echo ===== OUTPUT doesn’t print a separator. It kills the whole compound command with === not found.

The agent reads that line at the start of every session. In one project’s session logs, my bash hook’s message for exactly that mistake shows up close to 500 times.

I used to read that number as failure. Now I read it as the hook doing its job. Every one of those was a mistake caught before it ran, with the fix handed back, and the agent’s next call was correct.

A rule in a steering file is a request. A hook is a check. You want both, and the check should tell the agent the answer.

How a PreToolUse hook talks back

Claude Code runs PreToolUse hooks before a tool call executes. The hook gets the call as JSON on stdin. If it prints a deny decision, the call never runs, and the reason string goes straight back to the model as the result of its tool call.

That last part is what most hooks waste. The reason isn’t a log line for you; it’s the next prompt the agent reads. “Command blocked” makes it guess. “Run this instead” makes it fix it in one step.

Here’s the core of the project hook I run on every Bash call:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.sh\"",
            "statusMessage": "Validating bash command against project rules"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
set -euo pipefail

CMD=$(jq -r '.tool_input.command // ""')

deny() {
    jq -nc --arg reason "$1" '{
        hookSpecificOutput: {
            hookEventName: "PreToolUse",
            permissionDecision: "deny",
            permissionDecisionReason: $reason
        }
    }'
    exit 0
}

# Only match at the start of the command or after a shell separator,
# so the trigger text inside a commit message or grep pattern doesn't fire.
START='(^|[;&|]\s*)'

if echo "$CMD" | grep -qE "${START}echo[[:space:]]+="; then
    deny "BLOCKED: zsh aborts on an unquoted word that starts with '=' (=== not found). Quote the separator: echo '===='."
fi

exit 0

Empty output means allow. Anything the script prints through deny comes back to the agent as the reason.

Every message names the replacement

Each rule in my hook says what’s wrong, why, and what to run instead. A few, lightly trimmed:

BLOCKED: stage by explicit path. Use 'git add -- <path> <path>' or
'git commit --only -m ... -- <paths>' (ignores foreign staged files).
Never -A / . / -a in this shared tree.

BLOCKED: do not sleep-poll background work. Run the long command with
run_in_background and end the turn (the completion notification wakes
you), or use a blocking call like 'gh run watch'.

BLOCKED: --dirty sweeps other sessions' uncommitted files (and sees
nothing after a commit). Pass the exact paths you changed:
'vendor/bin/pint app/Foo.php'.

BLOCKED: zsh ties the variable 'path' to $PATH; assigning it
(for path in …, path=…) wipes PATH. Rename the variable (file, p).

The comment above the staging rule in my script says it plainly: redirect instead of a bare denial. An agent told only “no” tries the nearest variation, which is often a second way to do the same wrong thing. An agent told the replacement just runs the replacement.

Notice that the reasons also give the why. “Sweeps other sessions’ uncommitted files” isn’t decoration. It’s what keeps the agent from finding a clever workaround that has the same effect, because now it knows what the rule protects.

What actually gets blocked

I counted the block messages across one project’s Claude Code session logs. These are mentions in the logs, not unique incidents, but the ranking is what’s interesting:

Rule Mentions
Unquoted = word in zsh ~490
git stash in a shared tree 50
Symlinking vendor/ into a worktree 45
git add -A / commit -a 42
sleep to poll background work 41
Infra command missing required flags 39
Starting a dev server the local host already serves 25
Unscoped full test-suite run 22
Zsh path variable 16
Destructive DB reset (migrate:fresh, db:wipe) 14
Linting with --dirty 14
artisan make:* without --no-interaction 12

Two things stand out.

The top of the list is shell trivia, not judgement. The model writes bash first and zsh second, and no amount of prose in CLAUDE.md changes the reflex. These are exactly the mistakes a regex catches perfectly and a steering file catches poorly.

The middle of the list is about other people’s work. Stash, add -A, --dirty lint: all of them are fine with one agent in one checkout, and destructive when several sessions share a working tree. A stash sweeps another session’s half-finished change out of the tree, and that session has no idea where it went. The hook enforces the boundary every time, which a prose rule can’t promise.

Hooks don’t train the model, and that’s fine

The ~490 number tells you something important: a hook teaches inside a session, not across sessions. The agent corrects itself on the next call, then a fresh session makes the same mistake again, because the model’s habits come from training, not from my files.

That changes what a hook is for. It’s not trying to make the mistake go away. It’s making the mistake cheap: one blocked call and one retry, instead of a compound command that half-ran, a wiped $PATH, or a stash that swallowed another session’s work. Without the hook, the zsh mistake aborts the whole chained command, so every useful step in it is lost along with the separator.

So I stopped trying to fix the repeat offenders with stronger wording. ALWAYS in caps did nothing the hook doesn’t do better. I wrote about that trap before.

How I write these now

Every rule comes from a session. Nothing in my hook was designed up front. The git history is a list of “record agent guardrails from recent sessions.” When the same mistake shows up twice and a pattern can match it, it becomes a hook rule.

Anchor to the command position. Match the command at the start of the line or after ;, &&, ||, or |. Otherwise a commit message that mentions git add -A gets blocked, and the agent learns to distrust the hook. Many of my hook’s later commits are false-positive fixes: quoted git options, escaped quotes, runtime flags like php -d memory_limit=1G in front of artisan.

Allow the read-only version. git stash list and git stash show are harmless, so the stash rule strips them before checking. A guardrail that blocks the safe inspection along with the dangerous write pushes the agent toward guessing.

Name the replacement, then the reason. One sentence of each. If you can’t name a replacement, think hard about whether the rule should exist, because the agent will go looking for one anyway.

Keep the prose rule too. The CLAUDE.md line and the hook do different jobs. The prose shapes what the agent tries first; the hook catches what slips through. Delete the prose and the hook fires more. Delete the hook and the mistakes run.

The general shape

Every time your agent gets something wrong, there are two places to put the lesson: in words the agent reads, or in a check the harness runs. Words are right for judgement calls. Checks are right for anything a pattern can match.

And when you write the check, remember who reads its output. It isn’t you. It’s the agent, mid-task, about to try again.

Write the deny message as the next instruction, because that’s what it is.

CJ
Reply by email
I read everything.
$ reply