用 Hooks 幫 AI 代理裝上煞車:兩個真實護欄的設計筆記

AI 代理最可怕的不是它做不到,而是它「太敢做」——這篇筆記記錄我怎麼用兩個 hook,把煞車裝回自己手上。

前言

我在工作中大量使用 Claude Code 處理專案管理文件、工單系統操作。用得越深,越發現一件事:寫在 prompt 裡的規則,模型不一定每次都遵守。真正踩過的雷有兩個:

  1. 有一次請代理「確認外部協作平台的 webhook 是否可用」,它直接發了一則訊息到正式頻道——未經授權,而且不可撤回。
  2. 代理反覆宣告「驗證完成、已對齊」,但實際上根本沒打開流程圖的源碼、沒跑檢查腳本。事後盤點長期使用紀錄,「誤解需求」與「方法錯誤」加起來出現了五十多次。

規則寫進 CLAUDE.md 之後有改善,但只要模型換個措辭、或上下文太長把規則擠掉,還是會破功。最後的解法是:把紅線從 prompt 搬進 hooks——用程式攔截,而不是用文字拜託。

護欄一:PreToolUse 攔截對外寫入

第一個護欄的核心觀念是:讀取自由,寫入攔截。read / search / list 這類動作不影響任何人,放行;但只要是「別人會看到、不可撤回」的動作——建工單、留言、更新頁面、發訊息——一律先停下來問。

作法是掛一個 PreToolUse hook,比對兩種情況:

  • 工具名稱屬於工單系統的 MCP 寫入工具(建單、留言、更新頁面、上傳附件……)
  • Bash 指令是對外部協作平台網域發出的 curl -X POST/PUT/DELETE/PATCH

命中就回傳 decision: "ask",附上一份檢查清單,強制走人工確認。簡化後的邏輯長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
input=$(cat)
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

block=0
case "$tool_name" in
mcp__tickets__create_issue|mcp__tickets__add_comment|mcp__tickets__update_page)
block=1 ;;
Bash)
if printf '%s' "$command" | grep -qE 'ticket-system\.example' && \
printf '%s' "$command" | grep -qE -- '-X ?(POST|PUT|DELETE|PATCH)'; then
block=1
fi ;;
esac

if [ "$block" = "1" ]; then
echo '{"decision": "ask", "reason": "🔴 對外寫入攔截,確認紅線後再批准。"}'
fi
exit 0

搭配一條寫進規則檔的原則,我覺得是整個設計裡最重要的一句話:

Prompt 裡的「執行」是意圖宣告,不是發射授權。

使用者在需求描述裡寫「執行」,代表「我想做這件事」,不代表「現在就送出去」。代理必須先列出「我要做什麼、目標物件、實際 payload 預覽」,停下來等使用者明確說「送」,才能動手。不論動作多小——哪怕只是五個字的留言——都一樣。

護欄二:Stop hook 擋下「我做完了」

第二個護欄處理的是更隱微的問題:代理宣告完成,但沒有真的檢查

這個 hook 掛在 Stop 事件上,在代理準備結束回合時,解析 transcript 檢查這一回合到底做了什麼。觸發條件有兩條路(OR):

  1. 武裝模式:使用者的 prompt 含驗證類字眼(驗證、檢查、對齊、你確定、改好了嗎、verify、audit……)→ 這回合結束前直接查核,不論代理最後怎麼措辭
  2. 窄關鍵字:代理的回覆含「驗證完成」「已對齊」「all consistent」這類宣告詞。

任一命中,就檢查「三件套」是否真的做了:

  1. 跑過檢查腳本(verify.sh
  2. 本回合至少 Read 過一份檔案
  3. 若話題涉及 PRD / spec / 流程圖,必須實際讀過 .svg.mmd 源碼,不能只看檔名

缺任何一項,回傳 {"decision": "block"} 並列出缺了什麼,把代理打回去補做。

實作上有兩個細節值得記:

  • 後設語境豁免:如果使用者是在討論這個 hook 本身(出現 hook、觸發詞、誤觸、假陽性這類字眼),就不武裝——不然連調整護欄的對話都會被護欄擋住。
  • 防無限迴圈:Stop hook 的輸入帶有 stop_hook_active 旗標,已經被擋過一次的回合直接放行,避免 block → 重跑 → 又 block 的死循環。
1
2
3
if [ "$stop_active" = "true" ]; then
exit 0 # 已擋過一次,放行,避免無限迴圈
fi

心得:護欄要寫在模型摸不到的地方

兩個護欄跑了一陣子之後,我的結論是:

  • Prompt 規則是「請求」,hook 是「執法」。 模型會換句話說、會在長上下文裡遺忘,但 hook 是確定性的程式,不受措辭影響。武裝模式的設計正是針對這點——只要使用者要求驗證,不管代理最後怎麼包裝結論,都得過三件套這一關。
  • 攔截點選在「動作」而不是「意圖」。 PreToolUse 看的是實際要呼叫的工具與指令,這是行為發生前的最後一道關卡,比在 prompt 層猜測意圖可靠得多。
  • 護欄自己也要防呆。 meta 豁免與 stop_hook_active 這兩個小設計,都是上線後被自己的護欄卡到才補的。

AI 代理的能力越強,越需要這種「模型改不了、繞不過」的外部結構。與其期待模型永遠自律,不如把紅線寫成程式。下一篇想聊聊怎麼用 YAML 當單一事實來源,讓決策文件自動串聯稽核。你的代理有裝什麼煞車嗎?歡迎交流。

參考資料: