跳到正文
DEV Community· 98dkcc488b-glitch·· 4 小時前AI 評分48

branch-guard hook 的解析錯誤:多打一個空格令分支名稱誤報

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

AI 導讀

作者在測試 Claude Code 的分支保護 hook 時,發現命令中的重複空格會令解析器把分支誤報為 `push`,而非 `main`。hook 仍按 fail-safe 規則封鎖推送;修正方式是在處理命令前正規化空白,並加入回歸測試,測試數由 26 增至 27。錯誤訊息雖未讓錯誤推送通過,仍可能誤導 LLM 後續推理。

正文

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

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"}}

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.

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')"

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.

來源:DEV Community · dev.to