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

> Source: <https://dev.to/98dkcc488bglitch/a-bug-in-my-own-branch-guard-hook-caught-by-typing-one-extra-space-5b8l>
> Published: 2026-10-16 09:03:34+00:00

**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](https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-4-hooks-1-week-of-sanity-54pb) and the testing in [#2](https://dev.to/98dkcc488bglitch/26-test-cases-for-a-20-line-bash-function-and-what-each-one-taught-me-about-claude-code-gbc).

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:

```
# 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...
```

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:

```
# Normalize runs of whitespace so `git  push` parses the same as `git push`.
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](https://dumkestar6.gumroad.com/l/vicuep) 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.*
