你的 hook 不是強制力,是一個關於強制力的宣稱
我有一支腳本,職責是掃描規則檔案,找出「寫了規則但沒有強制機制」的地方。它從來沒有被掛上去。半年下來三道閘沒在跑,而我原本以為的第四個,查證後發現是我自己搞錯。
我有一支腳本,職責是掃描我的規則檔案,找出「寫了規則但沒有強制機制」的地方。
它從來沒有被掛上去。我終於想到要跑它的那天,它一跑就失敗。
我先承認主流是對的
過去半年我在 Claude Code 外面蓋了一層強制機制。不是 prompt,是程式碼:55 支 shell 與 Python hook 掛在工具呼叫的前後,擋 git add .、把破壞性指令導向需要人類確認的 capability gateway、沒讀過的檔案不准改、偵測 agent 是不是在原地打轉、把每一次工具呼叫用 hash chain 寫進 append-only 的證據檔。
如果你讀過那幾篇寫得很好的 Claude Code hooks 文章,你知道它們的結論高度一致:CLAUDE.md 是建議性的,hook 才是強制性的。 指示是機率性的,而一個正確掛載、且失敗時會關閉的 PreToolUse hook 是確定性的。與其禮貌地要求模型,不如讓系統直接說不。
這個論點是對的。我整套東西就是建立在它上面。
但我讀到的多半停在同一個地方:裝好 hook,你現在有確定性的強制力了。有些確實談了個別 hook 的靜默失效。我沒找到的是——把強制層本身當成一個會衰敗、需要定期稽核的東西。
所以這個問題只能自己回答:半年後,你怎麼知道它還在跑?
三道閘,三種死法
我去查了。
一個壞掉的 JSON 檔,讓偵測器死了四天。
有支 hook 負責追蹤 agent 有沒有在進展:重複的相同呼叫、同一個指令重複失敗、同一個檔案反覆改。它把計數寫進一個 progress.json。
那個檔案是壞的。精確地說:一份完整合法的 JSON,後面接著 130 個字元的垃圾——某次並行寫入蓋掉一半留下的尾巴。
jq 是串流解析的。它讀完前面那份合法文件、把轉換結果寫進暫存檔,然後才撞到垃圾、以非零狀態離開。而寫入是 jq ... > "$TMP" && mv "$TMP" "$TRACKER"。非零就短路,mv 永遠沒跑。
暫存檔留下,計數檔永遠不更新。每一次都這樣。
計數檔的 mtime 凍在四天前。偵測器整整四天是死的,而沒有任何東西告訴我——死掉的偵測器跟乾淨的系統,輸出一模一樣,都是安靜。
同時那些孤兒暫存檔以每次工具呼叫一個的速度累積。我清掉的時候:2,844 個檔、136MB。清完之後我繼續工作,同一個 session 裡又長出八個。
最難看的部分:這支 hook 用的計數 schema,我的 backlog 裡標記為「已修復、結案」,附了通過的測試當證據。而最舊的那個孤兒暫存檔,日期跟結案日是同一天。測試過了,實際在用的那個 state file 從來沒遷移成功。 因為遷移邏輯只在檔案「不存在」時才建立新 schema,而檔案早就存在了。
一條沒有東西可以檢查的規則。
我的多 agent 議會協定裡有條我認為是承重的規則:任何標記為 [ASSUMED] 的前提,如果超過兩個子任務依賴它,必須在第一個 agent 動手前先驗證。
它一次都沒觸發過。
不是因為有人在無視它。是因為正典要求填一張 ## VERIFIED PREMISES 表,而我翻遍七份真實的議會 briefing,沒有一份有那個段落。它們用了三種不同的方式標記假設,沒有一種是規則在讀的那種。
規則沒有可解析的對象。33 天以來它是一句文件裡的話,沒有任何程式碼能對它動作。它不是被違反,它是惰性的。
修法不是把規則寫得更嚴厲,是給它一個結構化的咬合點:一個帶 id / status / evidence / 依賴題號的 premises 陣列。現在派發程式自己算依賴數,超過就拒絕派發。
還有那支偵測器本身。
開頭那支 governance-lint.sh,掃軟性語言、掃沒有宣告強制機制的規則、掃被誤放成政策的參考資料。它就是用來找「治理沒在執行」的工具。
它在我的 hook 設定裡出現零次。手動跑:
Violations: 4 Warnings: 6 Core governance lines: 530
LINT FAILED — 4 violation(s) must be fixed
四個規則檔完全沒有宣告任何強制機制。這支偵測器躺在硬碟上,功能正常、判斷正確,什麼都沒報告——因為從來沒有東西呼叫它。
第四個發現是錯的
我原本有第四個。這是這篇文章裡最有用的部分。
我有道守門程式把破壞性指令導向確認閘。它的檔頭註解,用作者本人的話寫著:這個 exit code「顯示訊息,但不阻擋」。
我讀了那句話,推論這道閘從來沒擋過任何東西,寫進文章、寫進 commit 訊息、推上 GitHub、寫進 agent 的記憶系統。
然後在整理這篇草稿的時候,我去查證了。
PreToolUse 的 exit code 2 會阻擋工具呼叫。 官方文件寫得清清楚楚。那道閘一直都在運作。
更難看的是,證據就在我自己的檔案裡。我上一個 session 的交接筆記,在一張「環境地雷」表格裡有一行:rsync 被擋 → capability gateway。我自己的 rsync 就是被那道閘攔下來的,而且我把它記下來了。
註解寫錯了自己的機制,機制本身沒問題。真正的缺陷是舊版把訊息送到 stdout,而 exit 2 情境下模型讀的是 stderr——擋有擋到,但被擋的理由可能沒有乾淨傳達。我的修改仍然是改進,只是不是因為我說的那個理由。
所以是三個,不是四個。我補了更正 commit,也把記憶系統裡帶著錯誤主張的那筆記錄 supersede 掉了。
這件事比那三個真實發現更值得寫下來。
我當時正在寫一篇關於「系統宣稱了它沒有的強制力」的文章。而我在寫的過程中,做出一個沒查證的自信斷言,並且把它散布到三個地方。能糾正我的證據——官方文件、還有我自己的筆記——全程都在手邊。我只是沒去看。
稽核你的治理機制,跟搞錯你的治理機制,是同一件事在不同的謹慎程度下執行。
我一直該擔心卻沒擔心的那個
為了做上面那則更正去翻官方文件,翻出一個比我稽核到的任何一項都嚴重的東西。
只有 exit 2 會擋。 exit 1——Unix 世界裡「出事了」的慣例回傳值——會被當成非阻擋型的 hook 錯誤:記一筆 log,然後工具照樣執行。
所以一支因為語法錯誤、set -u 下的未定義變數、找不到的執行檔、或逾時而死掉的守門程式,不會 fail closed,它會安靜地 fail open,唯一的痕跡是一行沒人會讀的 log。
你寫的每一支 hook,都距離「變成一個看起來還裝著的空殼」只有一次手滑。如果那道閘重要,它需要一個 error trap 把所有非預期失敗轉成 exit 2,而且你要親眼看過它這樣做一次。我的也沒有。
這比上面三個都糟,因為它不需要檔案損毀、不需要漏掛、也不需要半年。它只需要一個 typo。
同日更新:我照自己的建議去做,然後被否決了
寫完上面那段之後,我就去替自己那 15 支會擋東西的 guard 加 error trap。動手前把設計送給另外兩個模型審查。他們否決了,而且三點都對。
掛在 EXIT 的 trap 只看得到最終離開碼。 這些腳本沒有 set -e,中途失敗不會停下來,會一路跑到結尾那行明確的 exit 0,trap 看到的是乾淨的零。我想抓的正是「guard 中途死掉」,而它從我的 trap 前面直接走過去。
同一支腳本裡任何後續的 trap ... EXIT 會靜默取代它。 我實測過:後面定義了清理用的 trap 之後,set -u 撞到未定義變數是 exit 1 而不是 2。保護消失了,而且沒有任何跡象。我那 15 支裡已經有一支自帶 trap。
不是每支 guard 都該 fail closed。 我有三支是提醒型的——檢查你有沒有看過 diff、commit message 語言對不對、重試了幾次。它們的原始碼本來就寫著 mkdir -p "$STATE_DIR" 2>/dev/null || exit 0:當自己的環境壞掉時刻意讓路。強制它們 fail closed 的意思是,jq 一出問題就擋掉你所有的 shell 指令——包括你用來修 jq 的那些。
所以上面那段建議方向沒錯,但實質上不完整。誠實的版本是:fail-closed trap 只是一層很窄的保護,會被平常的 shell 寫法擊穿,而且它回答不了你真正在意的那個問題。
那個問題還是「這道閘會不會觸發?」——而唯一能回答它的是讓它觸發。每支 guard 一個黑箱測試:餵一個必須被擋的輸入,斷言它擋。不是檢查、不是離開碼、也不是 linter,包括我自己那支。
這正是上一段最後那句話已經寫過的:你要親眼看過它這樣做一次。 我差點跳過自己寫的限定條件。攔下我的是「在推出去之前先問別人」,而不是推出去之後才發現。
所以我現在的判斷
社群的共識是 hook 贏過 prompt,因為 hook 是確定性的。這句話是對的,我不收回。
但有另外半句沒人講出來:
hook 不是強制力,是一個關於強制力的宣稱。
prompt 失敗通常發生在你看得見的地方——你會親眼看到模型做錯事。hook 的失效則不一定會出聲,而我這三個都沒出聲,各自用不同的方式。壞掉的偵測器跟乾淨的系統,從外面看完全相同。這才是問題的核心:安全的狀態跟失效的狀態,產生的可觀察結果是同一個,就是什麼都沒發生。
所以大家缺的那一層不是更好的 hook,是檢查 hook 還活著的東西:
- 每一條宣告的規則,有沒有指名是什麼在強制它?那個東西存在嗎?它有在跑嗎?
- 偵測器的狀態檔停止推進時,有沒有任何東西會叫?
- 你修好的那份程式碼,是實際在執行的那份嗎?
- 你的 linter 回報零問題的時候,是真的沒問題,還是它根本沒跑?
我不覺得我解決了這件事。我只有一支 lint,現在終於掛在「動到治理檔案時才跑」的位置上,而且它現在還報一個我還沒決定怎麼處理的違規。這是起點,不是答案。
我改了什麼
修復並遷移壞掉的計數檔,偵測器復活,洩漏歸零。
把惰性的 [ASSUMED] 規則改成會拒絕派發的結構化檢查,附測試。
把 governance lint 掛在治理檔案被修改時觸發,而且只在那時。我刻意沒有掛在 session 開場:每次都出現的警告,是你會學會跳過的警告,而那正是這些規則當初死掉的方式。
給記憶系統的稽核紀錄加上 hash chain,抄的是我工具呼叫證據檔裡已經跑了一萬多筆的那套做法,附一個 --verify-chain 指令——沒有人能驗證的鏈只是裝飾。
把四個規則檔補上強制機制的宣告,並且明確標出還沒被覆蓋的部分。在一個 hook 只涵蓋一半條文的檔案上填一個乾淨的 Enforcement: hook,正是讓這一切開始的那種讓人舒服的謊。
如果你也在 coding agent 外面跑一層強制機制:現在挑一道閘,證明給自己看它會觸發。不是證明它存在,是證明它會觸發。
開頭那支 lint 已經獨立成一個 repo:governance-lint。約兩百行 bash,無第三方相依、不用安裝,指向你自己的 ~/.claude 就會告訴你答案。
curl -sO https://raw.githubusercontent.com/MakiDevelop/governance-lint/main/governance-lint.sh
bash governance-lint.sh ~/.claude
它能告訴你哪些規則有宣告強制機制,不能告訴你那個機制真的會觸發——後面這半才是難的,我沒有通解。但「這條規則沒有指名任何東西在強制它」本身就已經夠你看的了。
文中提到的記憶系統也是開源的:agent-memory-hall(Apache-2.0、MCP-native)。它處理的是 coding agent 的 session 連續性——不是「記憶存在哪裡」,而是「agent 有沒有真的做到它說下次要做的事」。
其餘那層守門程式還沒有打包。想要的人夠多,這件事會改變。
我是江中喬,專注於 AI Agent Architecture、Memory Governance 與 Cognitive Diversity,持續研究如何打造能夠長期協作、可信任且可治理的 AI 系統。