規則寫在 CLAUDE.md 裡,Claude 讀了,你也知道 —— 然後它還是在你最累的那天被漏掉。這不是「不知道規則」的問題,是「知道了還是沒做到」的問題,而前 16 天造的東西沒有一樣是對付這種失敗的:CLAUDE.md 對付不知道,測試對付做錯,都不對付這一種。
今天要補的就是這一層:hook —— 在工具動手之前跑一支程式,擋得住的就擋,擋不住的至少問一句。它不靠 Claude 記得、不靠你記得,靠的是「只要走這條路,寫入之前一定會經過這裡」。
hook 這個詞前面出現過幾次:Day 6 把它記成「之後的事」,Day 10 把它排在「設定 → 規則 → hook」的最後一步,Day 16 看到別人的 install 往我機器裡塞了六個。今天是第一次自己寫一個 —— 而且正好是 Day 10 那句的第三步:規則寫了,還是擋不住。
而促使我把它接上去的,是一件自己的事。
規則很簡單:文章 repo 要做去識別化,真實路徑必須是零。它有過兩種存在方式:
存在方式 A —— 寫在 CLAUDE.md 裡:每次發文前對著清單人工檢查一遍。
存在方式 B —— 寫成可執行的檢查:同一條規則變成 pattern,由 Day 13 那個 MCP server 掃描。
同一條規則,兩種存在方式,結果差很多:
| 存在方式 A | 存在方式 B | |
|---|---|---|
| 我知不知道這條規則 | ✅ 知道 | ✅ 知道 |
| 每次發文有沒有檢查 | ✅ 有 | ✅ 有 |
| 實際結果 | 一個真實使用者名稱公開了 95 天 | 第一次跑就抓到 |
八個出現位置(中英各四處),至少三次人工檢查。這是這 17 天裡我最不想寫、但最應該寫的一篇。
我原本要寫的句子是「這條規則寫在 CLAUDE.md 裡三個月,沒擋住」。為了把日期補準,我去數了每個版本裡「去識別化」出現幾次(右邊那欄是另外 grep 數的):
$ git log --format='%h %ad' --date=short -- CLAUDE.md | tail -2
b1628d8 2026-08-18 去識別化=4
d7a90a4 2026-07-06 去識別化=0
這是最早的兩筆(後面的改動都是 9 月寫這個系列時加的);7/6 那版一個字都沒有。這條規則進 CLAUDE.md 的日期是 2026-08-18,正是加上 medium-check 的同一個 commit。時間軸:
| 日期 | 發生什麼 |
|---|---|
| 2026-05-01 | 我在 Medium 發了一篇專門講 CLAUDE.md 的文章 |
| 2026-05-15 | 洩漏那篇發布 |
| 2026-07-06 | 這個 repo 才有第一版 CLAUDE.md,「去識別化」出現 0 次 |
| 2026-08-18 | 同一天:規則和工具在同一個 commit 進來,洩漏也在同一天被修掉 |
2026-05-15 → 2026-08-18 = 95 天。那 95 天裡,這條規則不在 CLAUDE.md 裡,只在我的記憶裡 —— 我甚至先寫了一篇教別人用 CLAUDE.md 的文章,才在兩個月後給自己的 repo 補上一份。
我以為我寫下來了。git 說沒有。
所以這個實驗其實沒有資格回答「CLAUDE.md 有沒有用」—— 它從來沒被單獨測試過。它回答的是更前面那個問題:
你以為寫下來了,和它真的在檔案裡,是兩件事。而只有後者能被 grep。
差別不在規則的內容,也不在我有沒有認真 —— 差在誰負責記得。而這次連「寫下來」這個動作本身都是靠記憶擔保的,那份記憶錯了 95 天。
寫在檔案裡的規則,要在正確的時機被想起來才有效。而「發文前」這個時機,正好是你最累、最想趕快按下發布的時候。
連存在方式 B 也是事後的:MCP 掃描是寫完了才告訴你錯。要對付「知道了還是沒做到」,得再往前一步 —— 不是寫完了才告訴你,而是在寫進去之前就不讓它進去。
Claude Code 的 hook 設定在 .claude/settings.json,重點是選對時機:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/deident-guard.py\""
}
]
}
]
}
}
.py 不是 .sh我原本要寫 shell 的。去識別化 pattern 要抓真實使用者名稱、又要放行已消毒的佔位符,所以用了負向前瞻 /Users/(?!you/|username/|USER/)[a-z0-9]+/。我在 Claude Code 裡叫它跑 grep -P,跑得好好的 —— 但 hook 不跑在那裡,跑在 sh 底下:
Claude Code 的 shell → grep 是 ugrep 7.8.4,-P:pcre2jit 前瞻可用
sh → grep 是 /usr/bin/grep,BSD 2.6.0-FreeBSD
-P → invalid option -- P
-E → repetition-operator operand invalid
而且這裡有個更細的坑:那個 ugrep 不是我裝的。 Claude Code 的 Bash 工具會注入一個自己的 grep() shell function (以 ARGV0=ugrep 呼叫它自帶的二進位)。所以「我測過了」這句話裡的「我」,其實是 agent —— 我測的是 agent 的 shell,而 hook 跑的是另一個 shell。
同一個字 grep,兩個環境是兩個程式。在 agent 裡測會過,換成 hook 跑就必死,而且是安靜地死 —— 你只會覺得今天很順,沒被擋過。Python 的 re 直接支援前瞻,所以改用 Python。
三個時機的分工:
| 時機 | 什麼時候跑 | 適合擋什麼 |
|---|---|---|
| PreToolUse | 工具執行之前 | 會寫進檔案的東西 —— 這三個裡唯一真的能「擋住」的時機 |
| PostToolUse | 工具成功執行之後 | 格式化、產生附屬檔案 |
| Stop | 一輪對話結束 | 全域檢查、跑測試 |
matcher 對應的是工具名稱:Write|Edit 這種只有名字和 | 的寫法,官方文件說是「用 | 分隔的精確字串清單」,不是正規表達式(含其他字元才會當 regex)—— 所以它就是「這幾個工具」,一個字都不能拼錯。
為什麼是 PreToolUse 而不是 Stop:洩漏一旦寫進檔案,下一個 commit 就進了 git 歷史,事後刪掉不算刪掉。要擋就要在寫入之前擋。
上面那支 hook 用的是最土的方式:sys.exit(2),stderr 就是理由。官方文件還有另一種 —— 印一段 JSON,在 hookSpecificOutput.permissionDecision 裡有四個值,日常會用到前三個:
| 值 | 效果 |
|---|---|
allow |
放行,跳過權限提示(settings.json 裡的 deny、ask 規則照樣生效) |
deny |
擋下,理由會餵回給 Claude |
ask |
跳出權限提示,由人決定 |
defer |
只在 claude -p 有效:暫停這次呼叫,讓外層程式(SDK、自製介面)收集答案後再接回來 |
ask 這個值,讓後面那張「什麼規則該放哪」的表多了一格。原本我以為只有兩格:有客觀對錯的變 hook、要判斷的留在 CLAUDE.md。ask 給了第三格 —— 要判斷、但不能自動放過的:hook 抓到可疑的就停下來問你,不擋你也不放它。誤報的代價從「被擋住」變成「被問一句」,人比較不會想去關掉它。
但 ask 有一個前提:要有人可以問。我在 claude -p 裡試了一次,hook 回 ask,非互動模式沒有人回答,那一次呼叫就被當成拒絕。沒有接上權限代答(--permission-prompt-tool 這類)的非互動呼叫裡,ask 等於 deny。
matcher 對的是工具名稱,而 MCP 的工具也有名稱 —— mcp__<server>__<tool>。所以 Day 15 那條「資料庫只能讀」的規則,除了 server 開檔時的唯讀,還可以在 Claude 這一端再守一次:
{ "hooks": { "PreToolUse": [ {
"matcher": "mcp__library-db__sqlite_execute",
"hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/sql-guard.py\"" } ]
} ] } }
sql-guard.py 看 tool_input.sql 的第一個字:SELECT/WITH/EXPLAIN 不表態,INSERT/UPDATE/DELETE/DROP 回 deny,其他回 ask。然後把 Day 15 那句「把《圖書館學概論》下架」再問一次:
sql-guard:這個資料庫只能讀。擋下 UPDATE。
這次 UPDATE 沒有到 server —— debug log 裡是 hook 回了 permissionDecision: deny。Claude 的回覆第一句就說「UPDATE 被 sql-guard 擋下來了」,知道自己被什麼擋;查到的狀態(id 4、available、沒有未歸還的借閱)照樣列給我。然後把它想跑的那句 UPDATE 貼出來:「你開好之後再叫我一次,我就直接執行 UPDATE books SET status = 'removed' WHERE id = 4。」
跟 Day 15 一樣的形狀,只是它給的兩條路都是要我去開權限(server 開寫入,或加一條 write: true 的 canned query),Day 15 那句「直接在資料庫端執行」這次沒出現。但別太早放心:同一個 hook 下,借出中的《SQLite 權威指南》也被擋了,那次它還是把「直接用 sqlite3 開 DB 檔」列成一條路 —— hook 擋的是那個工具呼叫,不是那個念頭。所以同一條規則現在有三個位置,各守一條路:
| 位置 | 守哪條路 | 擋不到什麼 |
|---|---|---|
| server 開檔唯讀(Day 15) | 經過 MCP 的寫入 | sqlite3 直接開檔 |
| Bash 的 permissions deny(Day 10、Day 15) | Bash 裡的 sqlite3 |
沒列進 deny 的寫法(例如換一支程式開檔) |
| Claude 端的 hook(今天) | Claude 送出的那個工具呼叫 | Bash、或另一個沒被 matcher 列到的工具 |
sql-guard 回 ask 的那一格也踩到一次:我要它跑 PRAGMA table_info(books),hook 回 ask、被當拒絕,它改寫成 SELECT * FROM pragma_table_info('books') 通過白名單,結果一模一樣。它沒有違規 —— 那句真的是 SELECT。但這說明只看第一個字的白名單,擋的是「寫法」不是「意圖」,而模型換寫法只要一輪。
第一,誤報比漏報更致命。
Day 13 提過那 25 條誤報。在 CLAUDE.md 的世界裡誤報無所謂,你看一眼就跳過;但在 hook 的世界裡,誤報會擋住你正常工作。連續被擋三次之後,人一定會去把 hook 關掉 —— 那時候它跟不存在完全一樣。
所以 hook 對規則精確度的要求,要比 CLAUDE.md 更高,而且要能認出「已經修好」的樣子(那個負向前瞻)。
第二,不是所有規則都適合變成 hook。
| 規則類型 | 放哪 | 例子 |
|---|---|---|
| 有客觀對錯、能寫成 pattern | hook | 真實路徑、真實類別名、金額用浮點數 |
| 需要判斷、有例外 | CLAUDE.md | 「用繁體中文回應」、「commit 一次一篇文章」 |
| 需要判斷、但不能放過 | hook 回 ask |
「這句 SQL 不在白名單也不在黑名單」 |
| 不可逆的操作邊界 | settings.json permissions |
git push(Day 10) |
硬要把第二類寫成 deny 的 hook,結果就是第一點講的那個誤報地獄;第三類是 ask 存在的理由。
第三,hook 自己也會腐爛。
這是 Day 11、Day 12 那條線的延續。hook 引用的腳本被改名,會發生什麼要看你怎麼叫它 —— 我試了一次(2.1.283):sh 腳本.sh 或直接執行,找不到檔是 exit 127,Claude Code 當成一般錯誤,照樣寫入,連提都沒提;python3 腳本.py 找不到檔剛好是 exit 2,也就是 hook 的「擋下」,於是每一次經過它的寫入都被擋。一個安靜地死,一個吵到你去把它關掉 —— 兩種都是它不再守你的路。而且比 slash command 更糟:你不會主動呼叫它,所以你不會發現它死了。
腳本改名是一種爛法,matcher 裡的名字過期是另一種。matcher 對的是工具名稱,而工具名會隨版本增減:上面那份設定裡的 MultiEdit,9/14 校這篇時問 2.1.270 的工具清單,已經不在裡面了。matcher 裡那個名字現在是死的,不報錯、不擋任何東西 —— 無害,但它示範了「腐爛」長什麼樣:少守一條你以為還在守的路。
處理方式是給 hook 自己寫一個負面測試:準備一個一定該被擋下來的檔案,定期試一次。擋不住就是它死了。
我寫了九個案例:Write / Edit / MultiEdit(當時還在;第三種要驗「第二筆才違規」抓不抓得到)、表格圖的 gen.sh、已消毒佔位符必須放行、非文章檔不管,以及 pattern 檔不存在時必須擋而不是放行—— 最後這條最重要,因為檢查器消失時安靜放行,正是 Day 13 那個坑的形狀。
九個全過。但全過本身不是證據,所以我把 hook 換成一支只有 sys.exit(0) 的空殼再跑,掉到 3/9,換回來又是 9/9。確認它會紅,綠燈才有意義。
然後真的去踩一次:
PreToolUse:Write hook error: deident-guard: 擋下 Write → story/_hooktest/index.md
樣式 /Users/(?!you/|username/|USER/)[a-z0-9]+/ 命中 '/Users/<真實使用者名稱>/'
這是去識別化紅線(CLAUDE.md)。換成 stand-in 再寫。
檔案沒有被建立 —— PreToolUse 是在寫入之前擋,不是寫完再刪。這一擋的成本是每次 Write 多 18 毫秒(deident-guard.py 約 100 行、要讀一次 pattern 檔),感覺不到。而這段輸出本身也得去識別化才能貼進文章:原始訊息裡就有真實家目錄,我寫進草稿的時候被自己的 hook 擋了一次。
它擋不到的地方:matcher 對應的是工具名稱,所以它只管 Write、Edit。我改用 Bash 的 heredoc 直接寫檔,它完全不會被觸發。這不是 bug,是邊界 —— hook 守的是一條路,不是整個房子,而知道它守哪一條,比以為它守全部安全。
到這裡 hook 都是我自己寫、自己裝的。但 Day 16 那個工具的 install,往專案的 .claude/settings.json 裝了兩個 hook —— PostToolUse(Edit|Write)每次編輯後跑它的 update、SessionStart 每次開 session 跑一次;家目錄那邊還寫進全域的 ~/.codex/hooks.json,matcher 是 Write|Edit|Bash。我沒有寫它,它在我每一次編輯之後跑。
官方文件把這件事講得很直接:hook 可以設在七個地方(使用者、專案、專案本機、plugin、skill 的 frontmatter、subagent 的 frontmatter、公司管理設定),跨層合併、不互相取代;hook 能讀也能改工具的輸入輸出,「只裝可信來源」。想知道現在掛了哪些,用 /hooks 看。
文件裡還有一條跟我 9/14 讀到的不一樣了:當時文件寫 hook 在啟動時快照、中途改了要重開;現在寫的是設定檔裡的 hook 改了,通常會被自動重新載入。我試了一次(2.1.283,claude -p、只放行 Write):叫 Claude 先用 Write 把一個「擋所有 Write」的 hook 寫進 .claude/settings.json,再寫另一個檔 —— 第二個 Write 就被它自己剛加的 hook 擋下了,不用重開。Claude 可以在 session 裡替自己加 hook,而且馬上生效。
所以護欄和攻擊面是同一個機制。你寫的 hook 在寫入之前擋你;別人裝的 hook 在你每次編輯之後跑,而你不會主動去看 .claude/settings.json。Day 16 那個「裝之前先做 checksum 快照」的習慣,真正要看的就是這種檔。而且不只裝的時候:Day 16 那個工具的 uninstall,把 .claude/settings.json 留成一份讀不起來的檔 —— 你自己寫的 hook 和 deny 一起失效。別人的 install 會放 hook,別人的 uninstall 也能讓你自己的 hook 整個不見。
三層護欄到這裡湊齊了,而且順序是有意義的:
| 層 | 檔案 | 對抗什麼 | 能不能被繞過 |
|---|---|---|---|
| 意圖 | CLAUDE.md |
不知道規則 | ✅ 會忘記 |
| 正確性 | 測試 + CI | 做錯了 | 🟡 測試沒寫到就繞過了 |
| 執行 | hook | 知道規則但沒做到 | 🟡 只守 matcher 列到的路,改用 Bash heredoc 就繞過 |
第三層對付的是最尷尬的那種失敗 —— 不是不知道,是知道了還是沒做到。而那 95 天的洩漏就是這種失敗的標本。它日常有三個答案(擋、放、問)、matcher 能列 Write、Edit、Bash、MCP 這幾種工具,但每一支只守它列到的那條路 —— 而且要查是誰放的。
規則寫下來只是第一步。真正的問題是:當你最累的時候,誰在幫你記得?
這一篇留下的心法:
規則寫在 CLAUDE.md 裡只是意圖;意圖會被忘記、測試沒寫到會被繞過,PreToolUse hook 才擋在寫入之前。「我以為寫下來了」要去 git 查,不要憑記憶 —— 而 hook 是誰放的,也要查。
明天:hook 管的是「發生什麼事的時候」;明天換成「到了某個時間」—— 讓 Claude Desktop 每天早上自己回頭數一次。
matcher 規則與 mcp__<server>__<tool>、exit code 2 擋下工具、permissionDecision 的 allow/deny/ask、${CLAUDE_PROJECT_DIR}、command hook 以 sh -c 執行、七個設定位置與合併、設定檔變更自動重新載入與 /hooks(2026-09-26 重查)):code.claude.com/docs/en/hooks
PRAGMA 那次 9/29 以 2.1.284 重跑);hook 與重問可照抄重跑(sql-guard.py 十五行)ask 在 -p 裡被當成拒絕、工具清單沒有 MultiEdit、sql-guard 擋下 UPDATE(資料庫不變)、session 中途寫入的 hook 立刻生效、hook 腳本不見時 python3 擋下所有寫入而 sh 照樣放行 —— 皆與正文一致