本週主題:用 hooks 把「不准碰這些檔案」變成一定會執行的規則,而不是寫在 prompt 裡祈禱 AI 記得。
1. 問題
你一定寫過這種規則:「不要修改 .env」「不要動 package-lock.json」。你把它放進 CLAUDE.md(Claude Code 讀取的專案說明檔)或 system prompt(系統提示詞),然後希望模型每一次都照做。多數時候它會照做,但「多數時候」不是保證。對話拉長、context(模型一次能看到的內容)被塞滿、或是任務本身看起來需要改那個檔案時,規則就可能被模型自己判斷為「這次例外」。
問題的根源是:prompt 裡的規則是「建議」,由模型決定要不要遵守。你要的是「強制」,也就是不管模型怎麼想,這個動作都做不出去。這兩者的差別,就像公司規定寫在員工手冊裡,和門禁系統根本不給你的卡刷開那扇門。
如果被改壞的只是一個測試檔,影響不大。但 .env 可能存放金鑰,lock 檔會牽動整個依賴樹,.git 目錄一壞就是版本歷史出事。這類檔案值得用「機制」保護,而不是「叮嚀」。
2. 原理
幾乎所有現代的 coding agent(能讀檔、改檔、執行指令的 AI 程式)都有同一種結構:agent 在執行每個工具呼叫(tool call,例如寫檔、跑 shell 指令)之前,先把這次呼叫的內容交給一段「你寫的程式」檢查。這段程式叫做 hook(掛鉤)。程式看完之後,只回答一件事:放行,或擋下並說明原因。
這個設計有三個關鍵性質,也是它比 prompt 規則可靠的原因。
- 確定性(deterministic):hook 是普通的 shell script 或程式,同樣的輸入永遠得到同樣的結果。它不會因為對話很長而「忘記」,也不會被說服。
- 在 agent 迴圈之外執行:模型只是提出「我想寫這個檔案」,真正決定能不能寫的是 hook。模型無法修改 hook 的判斷邏輯。
- 錯誤訊息會回到模型:被擋下時,你寫的原因會被當成回饋交給模型,讓它知道為什麼不行,進而換一個做法,而不是原地重試或默默失敗。
實作上通常分成三步:第一,選一個「在工具執行之前」的事件(event);第二,用 matcher(比對條件)限定只對哪些工具生效,例如只針對寫檔類工具;第三,在 script 裡讀取 stdin(標準輸入)收到的 JSON,從中取出目標路徑,與保護清單比對,不符合就用特定的 exit code(結束代碼)或 JSON 回應表示拒絕。
要注意的一個設計取捨是 fail-open 與 fail-closed。fail-open 指的是 hook 自己壞掉時,動作照常放行;fail-closed 則是 hook 壞掉時一律擋下。多數工具預設是 fail-open,因為不希望一個壞掉的 script 讓整個 agent 停擺。但這代表保護規則的 script 一旦寫錯,保護就悄悄失效,所以 hook 本身也需要測試。
3. 在 Claude Code 實作
以下做法對應 Claude Code 官方 hooks 指南中的「Block edits to protected files」範例。事件選 PreToolUse(工具呼叫執行前),matcher 選 Edit|Write(只針對編輯與寫檔工具)。
- 建立
.claude/hooks/protect-files.sh。script 從 stdin 讀取 JSON,用jq(命令列 JSON 處理工具)取出tool_input.file_path,和保護清單.env、package-lock.json、.git/比對,符合就把原因寫到 stderr 並以exit 2結束,其他情況exit 0。 - 用
chmod +x讓 script 可執行。 - 在
.claude/settings.json註冊:hooks.PreToolUse底下放 matcherEdit|Write,hook 類型command,指令為"$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh。 - 要求 Claude 修改
.env,確認編輯被擋下,且 script 的Blocked:訊息被傳回給 Claude。
其中 exit code 的規則依官方文件如下:exit 0 代表 hook 對這次呼叫沒有意見,對 PreToolUse 來說這不等於核准,原本的 permission(權限確認)流程照常進行;exit 2 代表 Claude Code 擋下這個動作,並把 stderr 的內容當成回饋。
我在一個全新的空目錄中實際執行了這個流程,使用預設設定並隔離個人環境,只允許 Read、Write、Edit 三個工具。先準備一個內容為 API_KEY=placeholder 的 .env,然後下同一個指令:「在 .env 附加一行 # rotated,再建立 notes.txt 內容為 hello」。
第一次執行,掛上 hook。結果 .env 完全沒變,notes.txt 正常建立。模型在回覆中明確指出是 project hook 擋下了編輯:
Only one of the two changes went through.
- **`.env`: not modified.** A project hook (`.claude/hooks/protect-files.sh`)
blocked my edit because `.env` is a protected file. It still contains only
`API_KEY=placeholder`.
- **`notes.txt`: created.** It's at `~/demo/notes.txt` and contains `hello`, with no trailing newline.
# 執行後檢查 .env:
API_KEY=placeholder第二次執行當作對照組:移除 hook 與設定檔,其他條件完全相同。結果模型直接改了 .env:
**.env:** ... I used the Edit tool instead. It appended `# rotated` after the
existing `API_KEY=placeholder` line.
**notes.txt:** I created it with the Write tool, containing `hello` ...
# 執行後檢查 .env:
API_KEY=placeholder
# rotated兩次的差別只有 hook。同樣的指令、同樣的模型,一次被擋、一次放行。另外,模型在回覆中提到,它的 shell 附加指令先被權限提示拒絕(因為測試沒有開放 Bash),而被 hook 擋下後,它說保護看起來是刻意的,沒有再嘗試繞路,並把決定權交還給使用者,這正是 stderr 回饋的作用。
測試環境:Claude Code 2.1.286,預設設定(不含個人 plugins、hooks、CLAUDE.md),測試日期 2026-10-01。模型為 sonnet,每次為單次 headless 執行(-p),共兩次。輸出經節錄,路徑已改寫為 ~/demo。
官方文件還提供另一種寫法:exit 0 並在 stdout 輸出 JSON,內容為 hookSpecificOutput 搭配 permissionDecision: "deny" 與 permissionDecisionReason。文件提醒兩種做法擇一,不要混用。
⚠️ 未實測:JSON 形式的 deny 回應。測試只涵蓋 exit 2 這條路徑,JSON 寫法的行為以官方文件為準。
此外,本文的 matcher 只涵蓋 Edit|Write。如果還允許 Bash 工具,模型理論上可以用 shell 指令(例如重新導向)寫入同一個檔案,繞過這個 hook;要完整保護,需要另外針對 Bash 加一個檢查指令內容的 hook,或直接用 permission 規則拒絕。
⚠️ 未實測:Bash 繞過情境。測試的 allowedTools 不含 Bash,也不會執行會寫檔的 shell 指令,所以這點是依 matcher 的語意推論,並非實測結果。
4. 搬到其他工具
這個方法不綁 Claude Code。我查了三個主流 coding agent 的官方文件,每一個都有對應機制,名稱與細節不同,但骨架一致:在工具執行前攔截、用 exit code 2 或 JSON 表示拒絕。
- OpenAI Codex CLI:hooks 設定在
~/.codex/hooks.json、~/.codex/config.toml,或專案內的<repo>/.codex/hooks.json、<repo>/.codex/config.toml。有PreToolUse事件可在工具執行前攔截。要擋下呼叫,可以回傳含permissionDecision: "deny"的hookSpecificOutput,或以 exit code 2 結束並把原因寫到 stderr。這和 Claude Code 的形式幾乎相同(官方文件,原網址 developers.openai.com/codex/hooks 會轉址到這裡)。 - Cursor:hooks 定義在
hooks.json,位置包含使用者層級的~/.cursor/hooks.json與專案層級的.cursor/hooks.json。事件名稱是beforeShellExecution、beforeReadFile、beforeMCPExecution這類「before」開頭的事件。exit code 2 等同回傳permission: "deny";其他非 0 的 exit code 預設為 fail-open,除非設定failClosed: true(官方文件)。 - Gemini CLI:hooks 寫在
settings.json,專案層級為.gemini/settings.json,使用者層級為~/.gemini/settings.json。對應的事件是BeforeTool。擋下的方式是 exit code 2(文件稱為 System Block,會中止該動作),或 exit 0 搭配 JSON 的"decision": "deny"。文件特別強調 script 除了最後的 JSON 之外,不可以對 stdout 輸出其他純文字(官方文件)。
因此要搬移這個做法,你只需要記住三件事:找到該工具的「執行前」事件、用它的 matcher 或事件名稱縮小範圍、用 exit code 2 或 JSON deny 擋下。保護清單和比對邏輯可以幾乎原封不動地重用,只需調整讀取路徑欄位的名稱。
5. 限制與代價
- 覆蓋範圍取決於 matcher:只攔
Edit|Write就管不到其他能寫檔的路徑(例如 Bash、MCP 工具)。保護的邊界要自己盤點,不能假設一條規則全部涵蓋。 - Exit 0 不等於核准:在 Claude Code 中,
PreToolUsehook 回傳 exit 0 只是「沒有意見」,原本的權限流程仍會執行。hook 適合做「禁止」,不適合拿來當作「授權」。 - 預設 fail-open 的風險:Claude Code 文件指出,exit 2 以外的多數失敗狀況(例如 script 找不到 jq)屬於非阻擋錯誤,動作會繼續。Cursor 也是同樣預設,需要
failClosed才會改為阻擋。所以 hook script 的相依工具要確認存在,並至少手動測一次「應該被擋」的案例。 - 子字串比對很粗:範例用
*".env"*比對路徑,會同時擋下.env.example或名稱剛好包含該字串的檔案。誤擋的代價是模型要回頭問你,通常可以接受;漏擋才是問題。 - 每次工具呼叫都多一次程序啟動:hook 是外部程式,會增加些許延遲。保持 script 簡短,且只在必要的 matcher 上掛載。
- 本文的實測範圍有限:只用一個模型、兩次單次執行驗證。它證明了機制有效,不能代表所有模型與所有情境的行為。
6. 延伸閱讀
- Automate actions with hooks(Anthropic,Claude Code 官方文件,本文範例與 exit code 規則的來源)
- Codex hooks(OpenAI,官方文件)
- Cursor hooks(Cursor,官方文件)
- Gemini CLI hooks(Google,官方文件)
📌 本週短訊
- Claude's new auto eval tool(Hamel Husain,2026-09-30)
作者評測 Anthropic 為 Claude Code 推出的 eval plugin:它提供
build_eval與hill-climb指令,從對話 trace 找出可能的失敗,讓使用者挑一個轉成 eval,產生可供人工檢視資料的 Markdown 檔,最後建立 code-based 與 LLM-as-Judge 的評分器。值得看:作者列出了限制,例如在探索資料之前就先要求建立 eval、評分器把多種失敗合併成單一指標,並建議先觀望,是少見的具體使用心得。
Claude Code 更新
| 版本 | 變更 | 對讀者的影響 |
|---|---|---|
| 2.1.286 | 修正工具或 hook 回傳物件、數字或布林值(而非文字)之後出現 API 400 錯誤的問題,包含已恢復的 session | hook 輸出非文字時不再讓對話壞掉 |
| 2.1.285 | 修正同步 hook 啟動的背景程序(例如 some-daemon &)持續占用輸出時,Claude Code 被卡住的問題;hook 現在會在自己的程序結束後不久完成 | hook 裡啟動常駐程序不會再拖住整個 session |
| 2.1.284 | 修正 hook 同時寫入 stdout 時,debug log 漏掉失敗 hook 的 stderr 的問題;失敗的 hook 現在也會記錄 status code | 用 --debug 排查 hook 為何沒擋下時,能看到完整失敗資訊 |