iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 17 篇

Day 17 Hook:規則寫下來不夠,要擋在寫入之前

  • 分享至 

  • xImage
  •  

規則寫在 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 天裡我最不想寫、但最應該寫的一篇。

然後我去查 git,發現存在方式 A 根本不存在

我原本要寫的句子是「這條規則寫在 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 掃描是寫完了才告訴你錯。要對付「知道了還是沒做到」,得再往前一步 —— 不是寫完了才告訴你,而是在寫進去之前就不讓它進去。

把它接上去:最常用的三個 hook 時機

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 日常會用到的三個答案

上面那支 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。

同一條規則的第三個位置:守 MCP

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 是誰放的

到這裡 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 每天早上自己回頭數一次。


參考資料

  • Claude Code 官方文件 — Hooks(事件清單、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
  • 本文實測環境:Claude Code 2.1.270、macOS,2026-09-14;守 MCP 那一節用 Day 15 的樣本,2.1.271、2026-09-17(PRAGMA 那次 9/29 以 2.1.284 重跑);hook 與重問可照抄重跑(sql-guard.py 十五行)
  • 2026-09-26 在 Claude Code 2.1.283 重跑:ask 在 -p 裡被當成拒絕、工具清單沒有 MultiEdit、sql-guard 擋下 UPDATE(資料庫不變)、session 中途寫入的 hook 立刻生效、hook 腳本不見時 python3 擋下所有寫入而 sh 照樣放行 —— 皆與正文一致
  • Claude Code 官方文件 — Settings:code.claude.com/docs/en/settings
  • 延伸閱讀:自己做一個 MCP server:237 行,第一次跑就抓到我三個月前洩漏的東西

上一篇
Day 16 裝一個 AI 工具之前,怎麼知道它動了什麼
下一篇
Day 18 排程:要 Claude 每天早上自己跑,先把「做什麼」寫成腳本
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言