Context: Last week I shipped a set of 4 Claude Code hooks that catch common agent mistakes — no-op edits, pushes to the wrong branch, out-of-scope writes, and unverified claims at the end of a turn. I wrote about them in article #1 and the testing in #2.
This piece is the small, embarrassing follow-up: a bug I found by dog-fooding the hooks on their own payloads.
block-wrong-branch.sh runs on PreToolUse(Bash) for git push. It extracts the target branch and matches it against .claude/branch-policy.json. Simplified:
case "$command" in
*"git push"*|*"git push"*) : ;;
*) exit 0 ;;
esac
tail="${command#*git push}"
read -r -a tokens <<< "$tail"
The detection step accepts both git push and git push (I knew doubled whitespace shows up in real commands — people copy-paste). The parser step uses ${command#*git push} to strip everything up to and including git push.
See the bug yet? I didn't, for a week.
I was writing new test cases and typed one with a doubled space by accident:
{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}
Fed it to the hook:
exit 2
stderr: Blocked: `git push` to branch "push" is not allowed by .claude/branch-policy.json.
Reason: not in allow-list [...].
The block was correct — nothing matched the allow list, fail-safe wins. But the branch name it reported was push. Not main.
${command#*git push} is pattern-matching for a literal git push with one space. The input had two. Shell parameter expansion doesn't collapse whitespace runs, so the prefix pattern never matched. tail was unchanged — still the full command git push origin main.
Then read -r -a tokens <<< "$tail" tokenizes it. Bash word-splitting does collapse whitespace, so tokens became [git, push, origin, main]. The parser picks the second positional argument as the branch → push.
Case-statement glob = whitespace-sensitive. Parameter expansion = whitespace-sensitive. Word-splitting = whitespace-insensitive. Three different rules in twenty lines of bash. The detection step forgave the double space; the parser didn't.
One line, at the top of the command handling:
command="$(printf '%s' "$command" | tr -s ' \t')"
Added a regression test ("git push origin main" must report blocked branch main, not push). 26 → 27 tests. Shipped as v1.3.
Two things.
One: the hook block was correct — the kit didn't let a bad push through. But the error message lied about why. That's exactly the behavior I built this tool to prevent in agents: not catastrophic wrong actions, just small, confident, wrong-shaped claims about what happened. "Blocked branch push" is a factually wrong feedback string the LLM would then try to reason about ("hm, I didn't try to push a branch called push, did I?"). Dog-fooding caught it before any user did.
Two: when you build hook scripts for an LLM, the LLM is going to read your stderr and reason about it. Vague or wrong error strings don't just hurt the user — they poison the agent's next turn. "Blocked: branch push not allowed" is in some ways worse than no feedback, because now the agent is going to try to work around a constraint that doesn't exist.
Adversarial inputs worth testing on your own hook parsers:
/, ., or -force as a literal substring (not as a flag)src:dst
tool_input key
My 27-test harness runs these in under 20ms. If your hooks don't have a harness, they'll have the bug.
PostToolUse(Edit|Write|MultiEdit), diffs the file against the index. Zero bytes changed → exit 2 with a correction prompt. Catches no-op edits where the LLM later claims "I updated X."PreToolUse(Bash) for git push, parses the branch out of the command and matches it against .claude/branch-policy.json. Fail-safe: unknown branch, block. PreToolUse(Edit|Write), matches the target path against allow/deny globs in .claude/scope.json. Fail-safe. Stop, scans the final assistant message for "I updated/created/added X" style claims and cross-references trust-audit.log. If the file isn't in the log, block with "either make the change or correct your previous message." Has a stop_hook_active loop-guard — a reader of article #2 pointed out the missing one, that was v1.2.
Hooks + install script + 27-test harness + two policy JSON files. MIT. On Gumroad with a launch coupon (SHOWDEV50 → half off).
If you build hooks and your parser trips on something weird, reply with the payload — I'll run it against mine. That's the kind of feedback that made v1.1, v1.2, and v1.3 each better than the last.
This post was drafted with AI assistance and reviewed for accuracy.