My AI agent can't do certain things anymore
I had a rule in my CLAUDE.md that said "never commit, I do that myself". It worked for weeks, until one afternoon it didn't. Now the rule lives in a hook and the agent can't just forget it.
This is about that move, from writing rules to enforcing them, with the three hooks I actually run every day and the one mistake that can turn a hook into something that runs commands you never asked for.
Why the rule in CLAUDE.md gets forgotten
CLAUDE.md is context. The model reads it at the start of the session, and then it reads everything else: files, test output, stack traces, a 400-line diff, my own messages. The rule is still in there, but it's one instruction competing with a lot of other text, and the longer the session goes on the less it weighs. Add a context compaction halfway through and the detail you cared about can get summarized away.
The failure is never dramatic and the agent isn't ignoring you on purpose. It finishes a fix, the tests pass, and the most natural next step in its head is "commit this". That's the exact moment the rule was written for, and it's when it weighs least.
So I stopped asking. Anything I care about enough to write in capitals goes into a
PreToolUse hook instead.
What a PreToolUse hook is
Claude Code runs your hook before every tool call that matches. It gets the call as JSON on stdin (the tool name, its input, the working directory) and it can answer with a decision. If it prints this:
{"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked: ..."
}}
the call doesn't happen, and the reason goes back to the model as feedback. If it prints nothing and exits 0, the call carries on through the normal permission flow. There's more to it (exit code 2 also blocks, and you can return allow or ask), but deny plus a reason is all I use. No matter how long the session is or what got compacted, the check runs, because it isn't the model doing it.
Write the reason as an instruction, because the model reads it and adjusts. "Blocked" on its own gets you a retry with a slightly different command. "Blocked, run the tests with uv run pytest" gets you the right command on the next try.
Hook 1: no commits
I commit by hand. I want to read the diff myself, and I have opinions about commit messages that no amount of prompting fixed. The only exception is one repo where I decided I'm fine with it. This is the script, trimmed:
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
printf '%s' "$cmd" | grep -qiE \
'(^|[^[:alnum:]_-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+commit([[:space:]]|$)' \
|| exit 0
deny() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}' "$1"
exit 0
}
printf '%s' "$cmd" | grep -qE '(^|[;&|[:space:]])cd[[:space:]]|git[[:space:]]+-C[[:space:]]|--git-dir|--work-tree' \
&& deny "Blocked: commit combined with cd or git -C. Run it from the repo directory."
dir=$(printf '%s' "$input" | jq -r '.cwd // ""')
case "$(git -C "$dir" remote get-url origin 2>/dev/null)" in
*[:/]your-org/your-repo|*[:/]your-org/your-repo.git) exit 0 ;;
esac
deny "Blocked: the user commits manually."
Two details took more than one attempt. The regex has to survive things like
git -c core.editor=vim commit, where there are flags between
git and commit. And the allowlist checks the working
directory, so a cd other-repo && git commit would commit
somewhere the check never looked. That's why anything combining a commit with
cd or git -C is denied outright.
Funny thing: while drafting this post I asked the agent to test that guard, and its
test command had a cd and a git commit in the same line.
The guard blocked the test of the guard. It had to write the test to a file first.
Hook 2: uv run pytest, always
This one came from a real bug hunt. My Python repos use uv. If the agent types
python -m pytest, that resolves to my global pyenv interpreter, which
has a newer version of a mocking library than the one the project pins, the one CI
uses. So the tests go green or red locally for reasons that have nothing to do with the code,
and you spend an hour chasing a difference that only exists on your laptop.
c=$(jq -r '.tool_input.command // ""')
r="${CLAUDE_PROJECT_DIR:-$PWD}"
u=0
for f in "$r"/uv.lock "$r"/*/uv.lock; do [ -f "$f" ] && u=1 && break; done
[ "$u" = 1 ] || exit 0
printf '%s' "$c" | grep -qE 'uv[[:space:]]+run[[:space:]]+python[0-9.]*[[:space:]]+-m[[:space:]]+pytest' && exit 0
printf '%s' "$c" | grep -qE '(^|[^[:alnum:]_.])python[0-9.]*[[:space:]]+-m[[:space:]]+pytest' \
&& printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked: this repo uses uv. Run the tests with uv run pytest, the global interpreter resolves different package versions than CI."}}'
exit 0
Look at the first few lines. This hook is global, and I work on a lot of repos,
several of them on poetry or plain pip. A global hook that
assumes uv everywhere would block perfectly valid commands in half of them. So it
looks for a uv.lock first and gets out of the way if there isn't one.
Any hook in your global settings needs that kind of gate.
Hook 3: no Jira IDs in the code
Ticket IDs in comments, docstrings and test names are noise for anyone who can't
open the ticket, and in an open source repo that's most readers. The agent loves them
because they're right there in the conversation. This hook runs on
Edit and Write, only for source files (swap ABC for your own project key):
jq -r '(.tool_input.file_path // "") as $f
| (.tool_input.new_string // .tool_input.content // "") as $new
| (.tool_input.old_string // "") as $old
| [$new | match("ABC-[0-9]+"; "g").string] as $nt
| [$old | match("ABC-[0-9]+"; "g").string] as $ot
| if ($f | test("\\.(py|ts|tsx|js|jsx)$")) and (($nt - $ot) | length > 0)
then "BLOCK" else "" end'
A small wrapper turns BLOCK into the same deny JSON as above.
Look at $nt - $ot. It only blocks IDs that the edit
introduces. If a file already has one from two years ago, editing the
line around it still works. Without that subtraction the hook would block every
edit to legacy code and you'd end up disabling it within a day.
The backtick trap
This is the one I wanted to write about, because it bit me and it isn't obvious.
A hook is shell code, whether it's inline in settings.json or in a
script file. Most of mine build the JSON answer with printf and double
quotes. Now think about how you'd naturally write the reason. You're used to
Markdown, the model is used to Markdown, so you write something like "use
`uv run pytest` instead", with backticks around the command.
Inside double quotes in bash, backticks are command substitution. They don't format anything, they execute. Here's a harmless version:
$ echo '{}' | bash -c 'printf "{\"permissionDecisionReason\":\"Use `echo RAN-INSIDE-HOOK >&2; date +%s` instead\"}"'
RAN-INSIDE-HOOK
{"permissionDecisionReason":"Use 1790632323 instead"}
The command inside the backticks ran, and its output replaced the text in the
reason. With `uv run pytest` in there, the hook launches your whole
test suite every time it fires, just to build an error message. With something like
`git push` it's worse. And nothing looks wrong, because the hook still
denies, the reason still shows up, it just says something weird.
So no backticks in hook strings, ever. Write the command as plain text, or put it in single quotes inside the double-quoted string, where they're just characters. I keep that line in my CLAUDE.md too, and yes, I see the irony.
Test the hook before you trust it
A broken PreToolUse hook usually fails open. If the script crashes or prints nothing, Claude Code treats that as "no decision" and the call goes through. So a bug in your guard doesn't show up as an error, it shows up as the guard quietly not working.
So before a hook goes into settings, I pipe fake input into it and read the output:
printf '{"tool_input":{"command":"git -c core.editor=vim commit -m x"},"cwd":"/tmp"}' \
| bash ~/.claude/hooks/commit-guard.sh
# -> {"hookSpecificOutput":{..."permissionDecision":"deny"...}}
printf '{"tool_input":{"command":"git status"},"cwd":"/tmp"}' \
| bash ~/.claude/hooks/commit-guard.sh
# -> nothing, exit 0
One call that should be denied, one that should pass. A macOS gotcha I hit:
there's no timeout command out of the box. I wrapped a test in
timeout 5 ..., the shell said command not found, the output was empty,
and empty looks exactly like allow. The allow cases all passed, and I read the
empty deny case as a typo on my side. It took me a while to notice the hook
had never run at all.
What hooks are not
They're not a sandbox. A regex over a shell string can be dodged by sh -c,
an alias, a variable, eval, and if you're defending against something
actually hostile you want real isolation, not grep. What hooks catch well is the
common case, a helpful agent taking the obvious next step you didn't want, and for
that a thirty-line script beats any paragraph in a prompt.
My rule of thumb now: if I've had to say it twice, it's a hook.
Written 28 September 2026 · tell me I'm wrong