cd /news/ai-agents/a-bug-in-my-own-branch-guard-hook-ca… · home › topics › ai-agents › article
[ARTICLE · art-151240] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

A bug in my own branch-guard hook, caught by typing one extra space

A developer found a whitespace-parsing bug in their own Claude Code branch-guard hook after a week of use: the detection step accepted both `git push` and `git push`, but the parser used `${command#*git push}`, a whitespace-sensitive parameter expansion that failed on doubled spaces, causing the hook to report the blocked branch as "push" instead of "main". The fix normalizes whitespace runs with `tr -s ' \t'` and adds a regression test, taking the harness from 26 to 27 cases, shipped as v1.3.

by read4 min views1 publishedOct 16, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-bug-in-my-own-bran…] indexed:0 read:4min 2026-10-16 · —