DEV Community

98dkcc488b-glitch
98dkcc488b-glitch

Posted on

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

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

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.


The hook

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:

# Only inspect git push commands.
case "$command" in
  *"git push"*|*"git  push"*) : ;;
  *) exit 0 ;;
esac

# Tokens after "git push".
tail="${command#*git push}"
read -r -a tokens <<< "$tail"
# ...pick the branch out of the positional args...
Enter fullscreen mode Exit fullscreen mode

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.

What I typed

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"}}
Enter fullscreen mode Exit fullscreen mode

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 [...].
Enter fullscreen mode Exit fullscreen mode

The block was correct — nothing matched the allow list, fail-safe wins. But the branch name it reported was push. Not main.

Why

${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.

The fix

One line, at the top of the command handling:

# Normalize runs of whitespace so `git  push` parses the same as `git push`.
command="$(printf '%s' "$command" | tr -s ' \t')"
Enter fullscreen mode Exit fullscreen mode

Added a regression test ("git push origin main" must report blocked branch main, not push). 26 → 27 tests. Shipped as v1.3.

Why I'm writing this up

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:

  • Doubled or trailing whitespace in commands
  • Branch names with /, ., or -force as a literal substring (not as a flag)
  • Refspecs with src:dst
  • Empty stdin / malformed JSON
  • Correct JSON with a missing 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.

The hooks, briefly

  • verify-write.sh — on 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."
  • block-wrong-branch.sh — on 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.
  • scope-fence.sh — on PreToolUse(Edit|Write), matches the target path against allow/deny globs in .claude/scope.json. Fail-safe.
  • audit-turn.sh — on 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.

The kit

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.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

I'd add a tab-separated git<TAB>push fixture to exercise detection as well as parsing. In the simplified case statement, only the one-space and two-space forms reach the parser, so normalization needs to happen before that gate too. Another useful pair is an actual push versus printf '%s' 'git push origin main': a substring match sees both, but only one runs Git. If the hook intentionally supports a limited shell grammar, rejecting unsupported forms with an explicit reason seems safer than treating "not recognized" as "not a push."

Collapse
 
reidmarlow profile image
Reid Marlow •

The stderr poisoning point is real. When a pre-tool hook rejects a command with misleading feedback, Claude Code usually starts editing surrounding flags or inventing nonexistent subcommands to satisfy the error text instead of fixing the underlying argument.

Another failure mode to add to the test harness is flag-order variations, specifically git push -u origin main or options with arguments like --repo=origin or --set-upstream. Positional token slicing after stripping git push breaks the moment an option precedes the remote or branch name. I ended up running a minimal argument-parsing loop over the tokenized array rather than fixed index offsets so any leading flags get drained before resolving the destination remote and ref.