上週,我發布了一套四個 Claude Code 鉤子,用於捕捉常見的 AI 代理錯誤,包括無效編輯、推送到錯誤分支、超出範圍的寫入以及回合結束時未經驗證的聲明。我在第一篇文章中介紹了它們,並在第二篇文章中討論了測試。這篇文章是一個小而尷尬的後續:一個我在對鉤子本身進行「狗糧測試」(dog-fooding)時發現的錯誤。
block-wrong-branch.sh 鉤子在 PreToolUse(Bash) 階段針對 git push 命令執行。它會提取目標分支並與 .claude/branch-policy.json 進行比對。簡化後的程式碼如下:
```bash
Only inspect git push commands.
case "$command" in
"git push" | "git push " ) : ;;
esac
Tokens after "git push".
tail="${command#*git push}"
read -r -a tokens <<< "$tail"
...pick the branch out of the positional args...
```
偵測步驟接受 git push 和 git push (我知道實際命令中會出現雙重空白字元,因為人們會複製貼上)。解析步驟使用 ${command#*git push} 來移除 git push 及其之前的所有內容。你看到錯誤了嗎?我一個星期都沒發現。
我在撰寫新的測試案例時,不小心輸入了一個帶有雙重空白字元的命令:{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}。將其輸入鉤子後,結果是 exit 2,標準錯誤輸出顯示:「Blocked: git push to branch "push" is not allowed by .claude/branch-policy.json. Reason: not in allow-list [...]」。
阻擋是正確的,因為沒有任何內容符合允許列表,所以故障安全機制啟動。但它回報的分支名稱是「push」,而不是「main」。這是因為 ${command#*git push} 模式匹配的是字面上的 git push(一個空白字元)。輸入命令中有兩個空白字元。
Shell 參數擴展不會壓縮空白字元,因此前綴模式不匹配。tail 變數保持不變,仍然是完整的命令 git push origin main。然後 read -r -a tokens <<< "$tail" 將其標記化。Bash 的單詞分割會壓縮空白字元,因此 tokens 變成了 [git, push, origin, main]。解析器將第二個位置參數選為分支,結果就是「push」。
案例語句的萬用字元匹配對空白字元敏感,參數擴展也對空白字元敏感,但單詞分割卻對空白字元不敏感。這二十行 Bash 程式碼中包含了三種不同的規則。偵測步驟容忍了雙重空白字元,但解析器卻沒有。
解決方案很簡單,在命令處理的開頭加上一行:command="$(printf '%s' "$command" | tr -s ' \t')"。這行程式碼會正規化空白字元,讓 git push 和 git push 都能被正確解析。
我還增加了一個回歸測試("git push origin main" 必須回報被阻擋的分支是 main,而不是 push)。測試數量從 26 個增加到 27 個,並作為 v1.3 版本發布。
我寫這篇文章有兩個原因。第一,鉤子的阻擋是正確的,它沒有讓錯誤的推送通過。但錯誤訊息卻謊報了原因。這正是我建立此工具來防止 AI 代理出現的行為:不是災難性的錯誤操作,而是關於發生了什麼的小而自信、形狀錯誤的聲明。「Blocked branch push」是一個事實錯誤的回饋字串,LLM 會試圖對此進行推理(「嗯,我沒有嘗試推送一個名為 push 的分支,是嗎?」)。「狗糧測試」在任何用戶發現之前就捕捉到了這個錯誤。
第二,當你為 LLM 建立鉤子腳本時,LLM 會讀取你的標準錯誤輸出並據此進行推理。模糊或錯誤的錯誤字串不僅會傷害用戶,還會毒害代理的下一個回合。「Blocked: branch push not allowed」在某些方面比沒有回饋更糟,因為現在代理將嘗試繞過一個不存在的限制。
值得在自己的鉤子解析器上測試的對抗性輸入包括:命令中的雙重或尾隨空白字元、包含 /、. 或 -force 作為字串(而非旗標)的分支名稱、帶有 src:dst 的引用規範、空標準輸入或格式錯誤的 JSON,以及缺少 tool_input 鍵的正確 JSON。我的 27 個測試套件在 20 毫秒內運行完成。如果你的鉤子沒有測試套件,它們就會有這個錯誤。
verify-write.sh:在 PostToolUse(Edit|Write|MultiEdit) 階段執行,將檔案與索引進行差異比對。如果變更了零位元組,則以修正提示退出並顯示 exit 2。捕捉 LLM 聲稱「我更新了 X」的無效編輯。
block-wrong-branch.sh:在 PreToolUse(Bash) 階段針對 git push 執行,從命令中解析分支並與 .claude/branch-policy.json 進行比對。故障安全:未知分支,阻擋。
scope-fence.sh:在 PreToolUse(Edit|Write) 階段執行,將目標路徑與 .claude/scope.json 中的允許/拒絕萬用字元進行比對。故障安全。
audit-turn.sh:在 Stop 階段執行,掃描最終的助理訊息,尋找「我更新/建立/添加了 X」風格的聲明,並與 trust-audit.log 進行交叉參考。如果檔案不在日誌中,則以「要麼進行更改,要麼修正你之前的訊息」阻擋。它有一個 stop_hook_active 循環保護,這是第二篇文章的讀者指出的缺失部分,已在 v1.2 中修復。
這套工具包含鉤子、安裝腳本、27 個測試套件以及兩個策略 JSON 檔案。採用 MIT 授權。可在 Gumroad 上購買,並提供啟動優惠券(SHOWDEV50 可享半價)。如果你建立鉤子,並且你的解析器在某些奇怪的輸入上出錯,請回覆並提供負載,我會用我的工具進行測試。正是這種回饋讓 v1.1、v1.2 和 v1.3 版本一個比一個更好。這篇文章是在 AI 協助下撰寫並經準確性審查。