大師篇 · 第 19 章
Hooks 與確定性自動化
寫進 CLAUDE.md 的規則,Claude 大概照做——但只有「大概」(官方文件提到大約七成遵循率)。有些事你不能接受「大概」:不能改到 .env、不能 force push、跑完一定要驗證才算完成。這一章教你把這類「絕不退讓」的規則,從軟性建議升級成每次必觸發、可以直接攔下動作的 Hook(鐵閘門)。你會學到怎麼判斷哪條規則該搬進 hook、用哪個事件能真正「擋住」高風險操作,以及達人在生產環境實際在用的幾種閘門。第 8 章已經帶過 hook 的基本設定,這裡接著講「什麼時候用、怎麼用得對」。
本章資料分四級,每個關鍵說法都有徽章
本章混用不同可信度的來源,正文會用 source-tag 徽章逐處標明:官方 出自 Anthropic 官方文件;達人 是具名實踐者的個人經驗;社群 是社群整理的做法;實驗性 是前沿、尚未定型、未來可能改的功能。看到非官方徽章,表示那是「值得參考」而非「官方保證」,採用前自己斟酌。
19.1 三種機制怎麼分工:CLAUDE.md、Skills、Hooks
在動手寫 hook 之前,先搞清楚一件事:Claude Code 給你三種「教它做事」的機制,各有各的脾氣。用錯機制,是新手最常見的坑——把該硬擋的事寫成軟建議,結果三不五時被忽略。
三者最關鍵的差別,是「會不會每次都生效」官方:
CLAUDE.md(廣域建議)
每次對話都載入的常駐記憶。適合放「希望它記得」的事,例如專案風格、命名習慣。但它是建議——官方說法約七成遵循,會被忽略。
Skills(按需知識)
放在 .claude/skills/<名字>/SKILL.md,只在相關時才載入、不佔平時的記憶空間。可以用 /名字 主動叫用,也能讓 Claude 自己判斷要不要用。
Hooks(確定性強制)
綁在「某個動作前後」的腳本,每次都一定會跑。例如「每次改完檔就跑一次 lint」「想動 migrations 資料夾就擋下」。是保證,不是建議。
一句話記法:希望它記得 → CLAUDE.md;需要時才用的專業知識 → Skills;一定要發生、不容商量 → Hooks。把該硬擋的規則塞進 CLAUDE.md,等於把保全工作交給一張貼在牆上的紙條——這一章後面講的就是怎麼把紙條換成真正的閘門。
為什麼不全部寫進 CLAUDE.md 就好?
因為 CLAUDE.md 越塞越長,Claude 反而越容易整份忽略(第 14 章會深談這個「臃腫反效果」)。把確定性需求搬進 hook,CLAUDE.md 才能保持精簡、被認真讀。兩者是分工,不是二選一。
19.2 Advisory vs Mandatory:哪些規則該搬進 hook
判準很簡單,問自己一句話:「這條規則,偶爾被破壞我能接受嗎?」達人(這套 advisory/mandatory 分流,整理自實踐者 Matthias Herbert 的 hook 心法)
- 能接受偶爾破壞(風格偏好、命名習慣、「盡量寫測試」)→ 留在 CLAUDE.md 當建議就好。
- 絕不能破壞(不准碰金鑰檔、不准 force push、跑完一定要驗證)→ 搬進 hook,變成機械強制。
搬進 hook 之後,你要認識三個最常用的「時機點」,以及 hook 怎麼表達「放行」或「攔下」官方:
| 時機(事件) | 什麼時候觸發 | 典型用途 |
|---|---|---|
PreToolUse |
Claude 要用某個工具之前 | 攔下高風險操作(改金鑰檔、force push)導向審批 |
PostToolUse |
某個工具用完之後 | 自動善後,例如改完檔自動格式化 |
Stop |
Claude 認為「我做完了」要收尾時 | 完成前強制驗證(測試沒過就不准收) |
hook 怎麼跟 Claude 溝通「行不行」?靠 :腳本結束時回 0 代表放行、回 2 代表攔下。沒有模糊地帶——這就是它比「建議」可靠的根本原因。
退出碼的完整表情:不是「非 0 就是失敗」
寫過 shell 腳本的人,直覺都覺得「回傳非 0 就代表失敗、失敗就該被擋下」——這個直覺在 hook 的世界裡是個陷阱官方。Claude Code 的退出碼其實是三種語意,不是「0 跟非 0」兩種:
| 退出碼 | 代表什麼 | stdout/stderr 去哪 |
|---|---|---|
0 |
無異議放行 | stdout 會被解析成 JSON(可以用來附加資訊、改寫輸入) |
2 |
攔下這個動作 | stdout/JSON 一律忽略;stderr 的內容會回饋給 Claude,讓它看懂為什麼被擋、該怎麼調整 |
其他非零碼(1、127……) |
「非阻擋錯誤」——動作照樣放行 | 不會擋下任何事,只在逐字稿顯示一則 hook error 提示,很容易被忽略 |
最容易踩的雷就在最後一列。很多人寫驗證腳本習慣用 set -e,或是沿用工具本身預設的失敗碼(很多程式失敗時預設就回 1),以為這樣就等於「攔下了危險指令」——實測卻是照樣放行,只是終端機角落多跳出一行你可能根本沒注意到的錯誤訊息社群。部署任何 PreToolUse 攔阻腳本之前,養成一個習慣:故意讓它踩雷一次,親眼確認退出碼真的是 2、動作真的沒發生,這比事後才發現閘門形同虛設划算太多。
官方範例儲存庫(examples/hooks/bash_command_validator_example.py)示範了怎麼分開這兩種「有錯」
# 解析 stdin 的 JSON 失敗:這只是「腳本自己出錯」,不是「驗證失敗」,
# 用 1,只顯示給你看,不會擋下任何工具呼叫
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"JSON 解析失敗:{e}", file=sys.stderr)
sys.exit(1)
# 抓到真正該擋的問題(例如指令用了 grep,建議改用 rg):
# 這才是要餵給 Claude 看、逼它改做法的驗證失敗,用 2
if problems:
for msg in problems:
print(msg, file=sys.stderr)
sys.exit(2)
這個骨架值得記住:1 留給「腳本自己壞掉」,2 才是「我要真的擋下這件事」。兩種都算「有錯」,但只有後者會真的攔住 Claude。
不是每個事件都擋得住
官方文件列出的 hook 事件其實遠遠不只 PreToolUse/PostToolUse/Stop 這三種,還有 SessionStart、UserPromptSubmit、PermissionRequest、ConfigChange、PreCompact……等三十餘種官方。但不是每一種都能被 exit 2 擋下:像 PreToolUse、PermissionRequest、UserPromptSubmit、Stop、SubagentStop、PreCompact 這類「動作還沒發生」的事件,exit 2 是真的能把它攔住;而 SessionStart、Setup、Notification、SessionEnd 這類事件,就算你 exit 2,也只是把錯誤秀給你看,流程照樣往下走——這幾種事件的性質本來就不是「守門」,是「通知」。
完整事件清單,去官方頁面對,不要背版本當下的數字
事件清單、每個事件能不能被攔下,這些細節隨 Claude Code 版本調整得相當頻繁,本章不打算窮舉。真的要設計一個新 hook 之前,先去 code.claude.com/docs/en/hooks 或 claude --help 對應的段落確認一次那個事件目前的行為,比背下某個版本當下的清單可靠。
關鍵:只有 PreToolUse 能真正「擋住」動作
這是整章最重要的一條社群(出自實踐者 Lloyd Pilapil 的生產級 hook 整理)。PostToolUse 是「事後」——動作已經發生了,它頂多幫你善後或回報,擋不住已經做掉的事。所以凡是「絕對不能讓它做」的防線,一定要架在 PreToolUse。把它當成你唯一的安全閘門。
19.3 設定要放在哪裡:作用域、優先序與寫法
前兩節談的是「什麼時候該用 hook」,這節談更實際的問題:hook 的設定實際上寫在哪個檔案、怎麼精準指定要攔哪個工具呼叫。這節資訊量比較大,建議對照你手邊專案的 .claude/settings.json 邊看邊摸。
五層作用域,疊加合併不是互相覆蓋
Claude Code 允許好幾層設定同時存在,各自管不同範圍官方:
| 設定檔位置 | 管的範圍 | 要不要進版控 |
|---|---|---|
~/.claude/settings.json |
你這台機器上所有專案 | 不分享,個人設定 |
.claude/settings.json |
單一專案,team 共用 | 進版控,跟團隊分享 |
.claude/settings.local.json |
單一專案,你自己這台機器的覆寫層 | 預設被 .gitignore 排除 |
| 企業 managed policy settings | 整個組織,由管理員統一發布 | 組織層級管控,個人改不動 |
外掛 hooks/hooks.json |
裝了該外掛的所有專案 | 隨外掛安裝/解除安裝 |
| Skill/Subagent frontmatter | 只在該 Skill 或 Subagent 存活期間 | 寫在該元件檔案裡 |
關鍵行為:這幾層設定是合併(merge),不是「後面蓋掉前面」。你在使用者層設一個 PostToolUse hook,專案層又設另一個,兩個都會生效、依序跑。也因為是合併而非覆蓋,官方做了一個貼心的去重:完全相同的 command/URL 只會執行一次,不會因為你不小心在兩層都寫了同一支腳本就跑兩遍。企業層還有一張王牌——管理員可以開啟 allowManagedHooksOnly,直接把使用者層、專案層、外掛層的 hook 全部鎖死,只認企業自己發布的那份。這在自學場景用不太到,但未來進團隊,若發現 hook「怎麼設都沒反應」,這是一個該懷疑的方向。
安全提醒:專案層的 hook 設定會跟著 repo 一起被 clone
.claude/settings.json 放在專案層,代表它屬於 repo 的一部分——你 clone 別人的專案,等於信任對方能在你的機器上跑指令社群。2026 年初,資安研究單位 Check Point 揭露一組編號 CVE-2025-59536/CVE-2026-21852 的漏洞:早期版本的 SessionStart hook,只要專案一打開就自動執行、不需要你額外二次確認;攻擊者只要把惡意 hook 藏進一個看起來人畜無害的 repo,你 clone 下來、開 claude,就中招了。Anthropic 之後把這個洞補上,開啟含有不受信任 hook/MCP 設定的專案時,會跳出更明確的強化警告對話框官方。給自己養成一個習慣:clone 陌生專案、要跑 claude 之前,先打開 .claude/settings.json 跟 .claude/hooks/ 資料夾看一眼——心態上跟你 review 別人的 PR 一樣,hook 設定本質上就是「會自動執行的程式碼」,不是無害的靜態設定。
matcher 怎麼寫:精確字串、| 分隔、regex 陷阱
每個 hook 用 matcher 決定「這次工具呼叫,該不該讓我出手」。它的判斷規則比看起來嚴格官方:只要字串裡只出現英數字、底線、連字號、空格跟 |(近期版本也開放用逗號),就會被當成精確字串或用 | 分隔的列表比對;但只要出現其他字元——哪怕只是一個小數點——整串 matcher 就會被當成不加錨點的 JavaScript regex 來解讀。空字串、省略不寫、或寫 *,都代表「全部符合」。
一個小數點,讓 matcher 從「精確比對」變成「regex」
寫 "matcher": "Edit|Write" 是精確字串列表,只匹配這兩個工具名稱,安全。但如果手滑寫成 "matcher": "Bash.js"(想篩「跟 .js 有關的 Bash 指令」),那個 . 會讓整串變成 regex——. 在 regex 裡代表「任一字元」,於是它會比對到一大堆你沒想到的字串,範圍比你以為的寬很多。想比對真正的句點字元,要手動跳脫成 \.。
另外兩個常被忽略的細節:matcher 對大小寫敏感,"Bash" 跟 "bash" 是兩回事;MCP 工具的命名規則是 mcp__<server>__<tool>,如果是外掛內建的 MCP server,還會多一層變成 mcp__plugin_<plugin>_<server>__<tool>——要攔 MCP 工具呼叫,matcher 要照這個格式寫,少一個底線都比對不上。
想篩更細,用 if 欄位——但它不是安全邊界
matcher 只能篩到「工具名稱」這個層級(例如整個 Bash)。如果你只想攔「git 開頭的指令」,近期版本加了一個 if 欄位,用權限規則的語法(像 Bash(git *)、Edit(*.ts))在 matcher 之外再篩一層,只在 PreToolUse/PostToolUse/PostToolUseFailure/PermissionRequest/PermissionDenied 這幾種跟工具直接相關的事件上生效——確切支援的版本與語法細節請以 claude --help 或官方頁面為準,這是比較新才加入、還在演進的功能官方。
if 過濾器會「fail open」,不是安全閘門
if 過濾器碰到解析不了的 Bash 指令(管線、$()、反引號、變數插值這類複雜組合),會直接放行讓 hook 照跑,不會因為「解析失敗」就自動幫你擋下來。官方文件把這種行為稱為 ——這代表它是個「盡力而為」的粗篩,不是密不透風的防線。真正的硬管制,還是要交給正式的權限系統(第 7 章)本身的 deny 規則。if 適合拿來減少雜訊(例如「只有 git 指令才需要跑這支慢腳本」),不適合當唯一防線。
多個 hook 同時匹配:平行執行,deny 優先
如果同一個事件、同一次呼叫,剛好有好幾個 hook 都命中,Claude Code 不會排隊一個一個跑,而是全部平行執行到完成,再合併結果官方。這裡有兩個反直覺的地方要注意:
- 一個 hook 說「擋」,不會中止其他 hook 的副作用:假設你同時掛了一個「記錄用」的 hook(永遠
exit 0,只是把事件寫進 log)跟一個「攔阻用」的 hook(符合條件才exit 2),攔阻的那個回 deny,記錄的那個照樣把這筆記錄寫進去——這其實是官方建議的「log 與 guard 分離」模式,比把記錄跟攔阻邏輯塞進同一支腳本更好維護社群。 - 多個權限決定合併,最嚴格者優先:
PreToolUse若有多個 hook 各自給出不同決定,優先序是deny>defer>ask>allow——只要有一個說不行,結果就是不行。至於附加說明文字(additionalContext),每個 hook 給的都會保留、一起餵給 Claude 看,不會互相覆蓋。
別讓兩個 hook 搶著改同一個工具參數
如果你想用 hook 改寫 Claude 送出的工具參數(updatedInput),要小心:因為是平行執行、順序不保證,若有一個以上的 PreToolUse hook 都想改同一個參數,最後生效的是「最後跑完」的那個,不是你設定裡寫的順序,容易造成很難重現的怪行為。官方的建議很直接:同一個工具輸入,別讓一個以上的 hook 去改它,要嘛分工改不同欄位,要嘛乾脆合併成一支腳本。
19.4 Hook 不只是 shell 指令:五種型別與怎麼選
到目前為止,本章所有例子都預設 hook 是「跑一段 shell 指令」。這是最常用的一種,但其實只是五種型別之一官方:
command(最常用)
跑一段 shell 指令,透過 stdin/stdout/退出碼溝通。本章前面所有例子都是這種,速度最快,沒有額外的模型呼叫延遲。
http
把事件的 JSON 用 POST 送到你指定的網址,回應同樣要用 JSON 決策格式。只有 2xx 狀態碼才算數——單純回 403 沒有用,得回 200 並在 JSON 裡明確寫 deny,HTTP 狀態碼本身沒辦法拿來擋動作。
mcp_tool
直接呼叫一個已經連線的 MCP server 上現成的工具,不用自己另外寫腳本或架服務。
prompt
把判斷丟給一個 Claude 模型做單輪快速判斷,預設用最快的模型(等級接近 Haiku),只回傳一個簡單的 { ok: true/false, reason: "..." }。
agent(實驗性)
生出一個有工具存取權的子代理,可以自己讀檔、跑指令,看過實際狀態再下判斷。官方標示為實驗性功能,正式環境建議優先用 command 型。
怎麼選:延遲跟複雜度是一起漲的
五種型別能處理的「判斷複雜度」不一樣,延遲也跟著遞增達人:command 適合純機械檢查(格式、命名、正規比對),沒有模型呼叫,最快;prompt 適合需要一點語意判斷、但不需要看程式碼實際狀態的情境,例如「這段修改有沒有動到認證邏輯」;agent 才用在必須驗證「程式碼實際狀態」的場合,例如「測試是不是真的通過」,因為它能自己跑指令查證,不是純猜測——但也因此延遲最高。正式環境的排序建議:能用 command 解決就別升級,agent 型審慎使用。
想看 prompt 型跟 agent 型 hook 實際怎麼寫
prompt 型:只做語意判斷,不用碰實際檔案
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}]
}]
}
}
agent 型:真的去跑測試,不是只憑文字判斷「應該過了」
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results.",
"timeout": 120
}]
}]
}
}
兩者的差別一眼就看得出來:prompt 型只憑對話內容「用猜的」,agent 型會真的去執行、timeout 秒內看真實結果再回答。這就是為什麼 agent 型比較慢,但也比較可信。
exec form vs shell form:一次解決 Windows 路徑與注入風險
command 型 hook 還有一個藏在設定寫法裡的細節,會決定它穩不穩、安不安全官方:設定裡有沒有寫 args 陣列。
exec form(有 args,哪怕是空陣列)
直接 spawn 執行檔,不經過任何 shell 解析。參數逐一傳遞、特殊字元原樣傳入、路徑含空白不用加引號,天然免疫 。
shell form(沒有 args)
丟給 bash(Windows 上是 PowerShell 或 Git Bash)解析,你得自己處理引號、管線、變數展開,出錯的地方也多。
乾脆都寫 exec form,省掉一整類麻煩
只要在設定裡加一個 "args": [](哪怕內容是空的),就會走 exec form。這是排除 Windows「路徑含空白被拆成多個參數」(例如 C:\Users\JOHN DOE\...)與指令注入風險的一次性解法,比逐一手動補引號可靠得多。另外提醒 Windows 使用者:.sh 腳本如果用 CRLF 換行儲存,會讓 hook 靜默失敗,存檔時記得確認是 LF。
路徑替身變數:讓同一支腳本到哪個專案都能用
寫 hook 腳本時,與其把路徑寫死,Claude Code 提供幾個替身變數,在 command/args/headers/input 各種型別裡都能用,也會同步匯出成環境變數官方:${CLAUDE_PROJECT_DIR}(目前專案的根目錄)、${CLAUDE_PLUGIN_ROOT}(外掛安裝目錄,注意外掛更新後這個路徑會變)、${CLAUDE_PLUGIN_DATA}(外掛可以長期寫入的資料目錄)。
SessionStart 事件還有一個專屬變數 CLAUDE_ENV_FILE:hook 把環境變數寫進這個檔案,之後每次 Bash 工具呼叫前都會自動把它當成前導腳本執行。這個機制搭配 CwdChanged(目錄切換)事件一起用,可以做出類似 direnv 的效果——每次 cd 進不同的子專案,環境變數自動跟著換,因為 Claude 的 Bash 工具不會自動繼承你 shell 裡裝的 direnv 掛鉤。
SessionStart 與 CwdChanged 都掛同一行,模擬 direnv 自動載入
# 兩個事件都掛這支指令:每次開新對話、或每次切換工作目錄,
# 都把 direnv 該載入的環境變數寫進 CLAUDE_ENV_FILE
direnv export bash > "$CLAUDE_ENV_FILE"
19.5 達人在用的六種生產級閘門
理論講完,來看真實世界。下面六種模式整理自實踐者 Lloyd Pilapil 的生產環境經驗社群——都是社群驗證有效、但非官方明文的做法,你可以挑需要的套用。
-
防線
擋住關鍵檔案(PreToolUse)
寫一個 hook,看到 Claude 要改
.env、middleware、或 API route 就回 exit 2 攔下。這是最該優先架的一道。 -
防線
擋住敏感邏輯(PreToolUse)
要動到登入授權(auth)、金流(payments)、資料庫這類高風險區域時,先攔下來要你親自確認,別讓它自動改下去。
-
防線
擋住危險指令(PreToolUse)
在正式環境裡跑
npm install/pip install之類會動到依賴的指令,除非明確是裝開發用套件(--save-dev),否則攔下。 -
善後
改完自動格式化(PostToolUse)
每次 Claude 改完檔,自動跑一次 Prettier/格式化工具,省得程式碼風格亂掉。這是「事後善後」,所以用 PostToolUse。
-
善後
改完自動型別檢查(PostToolUse)
改完跑一次
tsc --noEmit(只檢查型別、不產出檔案),第一時間抓出型別錯誤,不用等到 build 才發現。 -
通知
收尾時叮一聲(Stop)
Claude 收尾時,用 Stop hook 發一個系統通知(macOS 桌面通知)提醒你「跑完了,來看一下」。長任務尤其好用。
注意這六種的分工:前三種「絕對不能做」的防線全在 PreToolUse(呼應上一節),後三種「善後與通知」用 PostToolUse 或 Stop。你看出規律了——要擋,就架在動作之前;要善後,就掛在動作之後。
不用六種一次到位
真要開始,先架第 1 種(擋關鍵檔)和第 4 種(自動格式化)就很有感了。其餘等你遇到實際痛點再補。Hook 是越用越長出來的,不是一次設計完。
第一種怎麼寫:擋住關鍵檔案的完整範例
六種模式裡最該優先架的是第一種,這裡給一個可以直接改著用的完整範例社群。先是攔阻腳本本身:
.claude/hooks/protect-files.sh:命中保護清單就攔下
#!/bin/bash
# 讀 Claude Code 從 stdin 餵進來的事件 JSON,取出這次要動的檔案路徑
FILE_PATH=$(cat | jq -r '.tool_input.file_path')
# 不准碰的檔案清單,自己按專案需要增減
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
# 寫給 Claude 看的理由要印到 stderr,不是 stdout
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
註冊進 .claude/settings.json
{
"hooks": {
"PreToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}]
}]
}
}
存檔後別忘記 chmod +x
腳本存好之後,記得跑一次 chmod +x .claude/hooks/protect-files.sh 讓它可以被執行——這是新手最容易漏掉的一步,漏了會出現「command not found」之類的錯誤,卻誤以為是設定寫錯。
除了退出碼,還可以直接回結構化 JSON
PreToolUse 表達「攔下」不是只有 exit 2 一種寫法,也可以維持 exit 0,改成印一段結構化 JSON,把決定寫在 hookSpecificOutput.permissionDecision 欄位裡官方:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "請改用 rg 取代 grep,效能比較好"
}
}
兩種寫法最後都是攔下,效果一樣;差別是結構化 JSON 能把「攔下的理由」寫得更完整、格式更明確,也能表達 ask(轉人工確認)而不只是「行」或「不行」二選一。純 exit 2 寫起來比較快,結構化 JSON 資訊量比較大——看腳本需求挑一種,不用兩種都做。
19.6 閘門放對位置:寫一半 vs 收尾時
架 hook 還有一個容易踩的細節:閘門卡在哪個時間點。同樣是「測試要過」,卡錯地方會把 Claude 搞得綁手綁腳。
達人的共識是分兩層處理達人(整理自 Shrivu Shankar 與 Boris Cherny 的實踐):
寫到一半 → 只善後,別硬擋
改檔過程中用 PostToolUse 自動格式化就好。關鍵技巧:指令後面加 || true,讓格式化「就算失敗也不會中斷」整個流程,免得打亂 Claude 寫程式的節奏。
收尾/提交時 → 才架硬閘門
真正「測試沒過就不准放行」的硬閘門,架在 commit/收尾這一刻。這時擋才合理——半成品本來就會紅,寫一半就擋只會互相為難。
範例:PostToolUse 自動格式化,加 || true 確保不中斷流程
# 改完檔自動格式化;就算 format 出錯也回成功(|| true),
# 不打斷 Claude 正在進行的工作
bun run format || true
範例:收尾時的硬閘門,測試沒過就不准停
#!/bin/bash
INPUT=$(cat)
# 防無窮迴圈:如果這已經是被同一個 Stop hook 擋過一次的「被迫延續」,
# 直接放行,不要再擋一次
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')
if [ "$STOP_ACTIVE" = "true" ]; then
exit 0
fi
# 真正的驗證:測試套件有沒有過
if npm test --silent >/dev/null 2>&1; then
exit 0
fi
# 沒過:印一段 JSON,把「還沒做完」的理由塞進 reason 欄位
echo '{"decision": "block", "reason": "測試套件未通過,請先修正再結束"}'
exit 2
stop_hook_active 是防呆用的,別漏掉這段檢查
Stop hook 有個特殊之處:它擋下 Claude 之後,Claude 會「被迫繼續做事」,做完又會再次觸發同一個 Stop hook——如果腳本沒檢查 stop_hook_active,理論上會無限循環下去官方。Claude Code 本身有個保險:同一個 Stop hook 連續擋滿 8 次會被系統強制覆蓋放行,這個上限可以用環境變數 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 調整。但保險是最後一道防線,不是拿來依賴的——養成檢查 stop_hook_active 的習慣,才是正解。
另外,SessionStart(每次開新對話時)和壓縮記憶之後這兩個時機,也可以用 hook 把關鍵 context「重新注入」一次,確保 Claude 不會因為記憶被壓縮而忘了重要前提。
背景執行:async 與 asyncRewake
有些 hook 天生就跑得慢(例如掃一次完整的安全性檢查),你不會希望 Claude 乾等它跑完才能繼續工作。這時候可以加 "async": true,讓這個 hook 變成非阻塞——Claude 不等它,逕自往下做官方。如果你還想要這支背景腳本「跑完之後有辦法叫醒 Claude」(例如背景掃描真的抓到問題,想事後回報),再加一個 "asyncRewake": true:它結束時如果 exit 2,stderr/stdout 的內容會用「系統提醒」的形式送回對話——很適合「先放行、等真的有問題才打斷」的場景。
背景 hook 忘記重導向輸出,看起來會像卡住
背景執行的 hook 如果沒有明確把輸出重導向掉,有可能繼承到不該繼承的檔案描述符,變成孤兒行程卡住整條 pipe,表面上看起來像「沒反應」,其實是資源沒關乾淨社群。另外,hook 執行環境本來就沒有「控制終端」,直接寫 /dev/tty 想跳通知會失敗;真的要跳桌面通知或改終端機標題列,改用 JSON 輸出裡的 terminalSequence 欄位回傳對應的 escape sequence,Claude Code 會幫你送出,比自己手動控制終端可靠。
「審批轉發到 Slack/WhatsApp」不是內建功能
你可能在某些文章看過「用 hook 把審批請求自動轉發到 Slack 或 WhatsApp」。釐清一下社群:Claude Code 沒有內建這個轉發功能。要做到,你得自己接一套 MCP 整合(第 9 章講過 MCP)把 hook 事件送出去。別誤以為設個 hook 就會自動發通知到通訊軟體——那需要額外自架。
19.7 給多代理用的閘門:逼它做到達標才放手
如果你用到第 16 章講的 Agent Teams(多個 Claude 平行協作),hook 還有一招進階用法:把品質閘門架在「隊友」身上,逼它持續工作到達標,不准提早收工。
機制是這樣官方實驗性:Agent Teams 提供 TeammateIdle(隊友閒置時)、TaskCreated(任務建立時)、TaskCompleted(任務完成時)這幾個時機。你在這些 hook 裡回 exit 2 + 一句回饋,就能把「準備閒置/準備收工」的隊友擋回去,附上「還沒達標,繼續做」的訊息,逼它接著做下去。
這招的價值,是結構性地對抗「假裝做完」——不靠提醒它「要認真」,而是用機械閘門卡住「沒達標就不放行」。這跟本章一開始的核心精神一致:與其拜託它,不如架一道它繞不過去的閘門。
寫法上跟 Stop hook 是同一套邏輯,只是換了觸發的事件:腳本讀進 stdin 的事件 JSON,判斷「這個任務、這個隊友,真的達標了嗎」,沒有就 exit 2 並把還缺什麼寫進理由,逼它接著做;真的達標了就 exit 0 放行。跟第 15 章提過的「驗證閉迴圈」是同一個核心精神,只是這裡守的不是「Claude 想收工」,而是「隊友想閒置或想結案」。
這是實驗性功能,可能變動或移除
這幾個 teammate 事件依附在實驗性的 Agent Teams 之上實驗性。官方文件明說它們尚未進入穩定版,未來可能改名、改行為、甚至拿掉。可以拿來實驗、理解概念,但別把正式流程綁死在上面。引用前先對一次當前官方文件。
19.8 進階:把 hook 事件變成可觀測的儀表板
當你的 hook 越架越多、又跑多代理時,會想知道「到底發生了什麼、誰在什麼時候做了什麼」。社群有人把這件事做成了一個即時儀表板,值得認識——但這是社群專案,不是 Claude Code 內建,當作開眼界、需要時自己搭。
想知道做法:hook 事件可觀測性儀表板(社群專案)
實踐者 disler(IndyDevDan)做了一套開源工具,追蹤 12 個 hook 生命週期事件社群。資料流大致是:每個 hook(PreToolUse、PostToolUse、子代理啟動/結束、對話開始/結束、壓縮前、收尾等)觸發時,送一個 JSON 事件出去 → 經一支小程式 HTTP POST → 後端伺服器 → 寫進本地資料庫 → 再用 WebSocket 即時推到瀏覽器,畫成一條條時間軸,可以依對話或來源分流過濾。
它解決的痛點是:從「憑感覺猜 Claude 在幹嘛」變成「看著資料除錯」。裡頭有個關鍵防呆叫 stop_hook_active 守衛——Stop hook 自己可能觸發新動作、又再觸發 Stop hook,形成無窮迴圈;這個守衛就是用來偵測「我是不是已經在收尾流程裡了」,避免鬼打牆,正是 19.6 那支範例腳本裡 stop_hook_active 檢查在做的事,只是這裡用在一整套可觀測系統上。
你不一定要自己搭這套。重點是理解:hook 不只能「擋」和「善後」,還能當成觀測系統的資料來源——每個事件都是一筆可記錄、可分析的訊號。
19.9 卡住怎麼辦:除錯與常見錯誤排除法
hook 寫錯很難一眼看出來——它不像網頁排版跑掉那樣有畫面可比對,出錯常常就是「安靜地什麼都沒發生」,或反過來「安靜地照樣執行了」。這節整理三種偵錯管道,加上幾個新手(甚至老手)最常踩的坑。
三種偵錯管道
/hooks 指令:看設定,不看執行過程
在 Claude Code 裡直接打 /hooks,會開一個唯讀瀏覽器,列出每個事件底下設定了幾個 hook、matcher 是什麼、來自哪一層設定檔(使用者/專案/local/外掛/Skill/內建),以及完整的指令內容。懷疑「這個 hook 到底有沒有被讀到」,先查這裡。
--debug-file 或 /debug:看執行過程
用 claude --debug-file /tmp/claude.log 啟動,或在執行中打 /debug 取得 log 路徑,再另開一個終端機 tail -f 追蹤,可以看到「哪些 hook 被匹配到、退出碼是多少、stdout/stderr 印了什麼」的完整細節。懷疑「hook 有跑,但結果不對」,查這裡。
逐字稿 Ctrl+O:看一行摘要
不想開額外的 log 檔,對話畫面裡按 Ctrl+O 可以切換顯示每個 hook 執行的一行摘要,適合快速確認「這一步到底有沒有觸發」。
最快的第一步:手動餵一段假 JSON 進腳本
與其在 Claude Code 裡反覆試錯,不如先確認腳本本身邏輯沒問題:echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?,直接看退出碼是不是你以為的那個社群。這樣可以快速分辨問題出在「腳本邏輯」還是「跟 Claude Code 的整合層」,比每次都要重新觸發一次實際操作快很多。偵錯的合理順序:先手動測腳本 → 再用 /hooks 確認有沒有設定對 → 最後才用 --debug-file 追即時 log。
常見錯誤與排除法
驗證腳本明明寫了檢查,危險指令還是被放行
十之八九是把退出碼 1 當成攔阻在用——回顧 19.2,只有 exit 2 才擋,其他非零碼一律放行、只顯示提示。部署前務必刻意讓腳本踩一次雷,親眼確認動作真的沒發生。
腳本明明印出合法 JSON,卻報「JSON 解析失敗」
shell form 的 command hook 若你的 shell profile(如 ~/.bashrc)裡有「無條件」的 echo,profile 印出的文字會混進 hook 的 JSON 輸出前面,把 stdout 弄髒。解法是把 profile 裡那類 echo 包進 if [[ $- == *i* ]]; then ... fi,只在互動式 shell 才印。
hook 完全沒被觸發
先查三個嫌疑:matcher 拼字或大小寫寫錯(matcher 對大小寫敏感)、事件選錯(PreToolUse 是「前」、PostToolUse 是「後」,兩者常被搞混)、或是在無人值守的 -p(headless)模式下設定了 PermissionRequest——這個事件在 headless 模式根本不會觸發,要攔的話得改設定在 PreToolUse。
「command not found」
腳本路徑寫成相對路徑,Claude Code 找不到,改用絕對路徑或 ${CLAUDE_PROJECT_DIR} 開頭;或是忘記 chmod +x;也可能是腳本裡用到 jq 但這台機器沒裝,得自己補上,或改用 Python/Node 處理 JSON。
把「攔下」誤當成「復原」
PostToolUse 系列事件觸發時,工具早就執行完畢了——就算 exit 2,也只能把 stderr 顯示給 Claude,讓它「之後」自己調整,沒辦法讓已經寫入的檔案或已經跑掉的指令復原。回顧 19.2:真正要擋下危險動作,一定要設定在 PreToolUse。
Stop hook 附加的說明「消失」了
想在 Stop hook 裡附加一段說明,欄位要放對——hookSpecificOutput.additionalContext 不在 Stop 事件允許的輸出格式裡,訊息會被靜默丟掉;要用 reason 欄位,才會變成 Claude 接下來真的會看到的指示文字(19.6 的範例就是這樣寫)。
19.10 小結
這一章的核心只有一句:絕不能退讓的規則,別寫成建議,要架成閘門。CLAUDE.md 是「希望它記得」(約七成遵循),Hooks 是「每次一定發生」(確定性強制)。判準是問自己「偶爾破壞我能接受嗎」——能接受就留 CLAUDE.md,不能就搬進 hook。
三個時機要分清楚:PreToolUse 是唯一能真正擋住動作的事件,所有安全防線都架在這;PostToolUse 用來事後善後(自動格式化、型別檢查);Stop 用來收尾把關(測試沒過不准收,記得檢查 stop_hook_active 防無窮迴圈)。退出碼只有 2 才是真攔阻,0 放行,其他非零碼(尤其容易誤用的 1)一律放行、只顯示提示——這是整章最容易誤踩的陷阱,部署前務必刻意測過失敗路徑。達人的六種生產級閘門你不用一次到位,先架「擋關鍵檔」和「自動格式化」就很有感。hook 不是只能寫 shell 指令,設定也不是只有一個地方放,但這些都是「需要時再查」的細節,不是動手前非背不可的知識;真的卡住時,先查 /hooks 有沒有設定對,再用 --debug-file 追執行過程。至於 teammate 閘門和可觀測儀表板,分別是實驗性功能與社群專案,理解概念、需要時再深入。也別忘了 .claude/settings.json 屬於 repo 的一部分——clone 陌生專案前,養成先看一眼 hook 設定的習慣。下一章我們把場景拉到無人值守:用 claude -p 在 CI 流水線裡跑 Claude,並把安全鎖死。
19.11 動手試試
-
動手做
盤點你的「絕不退讓」清單
拿你手上的專案,列出 2~3 條「偶爾被破壞無法接受」的規則(例如:不准改某個設定檔、提交前測試一定要過)。對每一條問一次「這該用 PreToolUse 擋、還是 Stop 收尾把關?」——光是分清這兩類,你就抓到 hook 設計的精髓了。
-
動手做
把一條 CLAUDE.md 規則「升級」成 hook
回去翻你的 CLAUDE.md,找一條其實「不能被忽略、卻只寫成建議」的規則(最常見的是「不要改 .env」)。回到第 8 章的 hook 設定,把它改成一個
PreToolUse閘門:偵測到要改該檔就回 exit 2 攔下。設好後故意叫 Claude 改那個檔,確認它真的被擋下來。預期會看到Claude 嘗試改該檔時被 hook 攔截、動作沒有發生——你親手把一條「軟建議」變成了「鐵閘門」。
-
動手做
加一個「改完自動格式化」的善後 hook
設一個
PostToolUsehook,在 Claude 改完檔後自動跑你的格式化工具,記得指令後面加|| true(例如bun run format || true),確保格式化萬一失敗也不會中斷流程。比較看看:擋的 hook 架在動作「之前」,善後的 hook 掛在動作「之後」——這個前後之分,就是整章的關鍵直覺。 -
動手做
親手踩一次退出碼的陷阱,再修正它
寫一支超簡單的
PreToolUse腳本,故意先用exit 1表達「我要攔下」,實際測一次:echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./你的腳本.sh; echo $?,觀察退出碼、也觀察 Claude 是否真的被擋下。接著把exit 1改成exit 2,重新測一次,比較兩次的差異。預期會看到exit 1時動作照樣放行,只在畫面角落多一行不起眼的錯誤提示;exit 2時動作才真的被擋下。親眼看過這個落差一次,你就再也不會忘記這個陷阱。