第 4 章 hooks
一句話:hook 是掛在固定時機的程式閘門——提示詞是「請它不要」,hook 是「它做不到」。
Hook 是 Claude Code 在特定時機一定會執行的程式:工具呼叫前、工具呼叫後、session 開始、你送出提示詞時……你可以掛一支 shell 指令或腳本上去,它讀到當下的資料(例如要改哪個檔),決定放行還是擋下。 它跟提示詞、CLAUDE.md 最大的差別是確定性。「src/ 底下沒有規格文件就不准改」這種規則寫進 CLAUDE.md 只是建議;寫成 PreToolUse hook 就是閘門——Claude 要 Edit 的那一刻被擋下,還會收到你寫的理由。 本章要你裝一個簡化版的 spec-gate,然後故意觸發它,看它把一次 Edit 擋下來。
原理:一次工具呼叫的時間線
Section titled “原理:一次工具呼叫的時間線”拿本章的 spec-gate 走一遍完整的時間線。你要求「在 src/todo.py 加一行註解」,Claude 決定呼叫 Edit——就在 Edit 真正執行之前,Claude Code 先把一份 JSON 從 stdin 塞給你的腳本:
- 腳本讀到
tool_input.file_path是…/src/todo.py,算出相對路徑落在src/底下; - 查
SPEC.md——不存在:往 stderr 印「已擋下:src/ 受 SPEC 保護」,exit 2。Edit 根本沒有執行,那行理由被送回給 Claude,它只能停下來回報你。 - 你自己建了
SPEC.md之後再來一次——這次腳本查到檔案存在:印一行[spec-gate] skip: SPEC.md 存在,exit 0,Edit 照常執行。
注意兩個關鍵:閘門卡在「決定」與「執行」之間,所以擋下時檔案完全沒被碰過;擋下的理由是你寫的文字,Claude 會讀它、照它行動——這是你跟閘門之間唯一的溝通管道,值得寫清楚。
- 有不能靠自覺的規則:沒 spec 不准改產品碼、不准碰 migration、不准
rm -rf。規則越硬,越該是 hook。 - 每次都要做的固定動作:改完檔自動跑 formatter/lint、session 開始時載入環境資訊。這些不需要 Claude「記得」,hook 會做。
- 要留下稽核紀錄:每次工具呼叫寫一行 log,之後可以回頭查。
不該用的情況:規則本身需要判斷(「這個改動合不合理」)——那是 review 的事,不是閘門。Hook 要的是能用程式判斷的條件。
設定放哪裡(三層)
Section titled “設定放哪裡(三層)”| 檔案 | 範圍 | 進 git? |
|---|---|---|
~/.claude/settings.json |
你所有專案 | 否 |
.claude/settings.json |
這個專案 | 是,團隊共用 |
.claude/settings.local.json |
這個專案、只有你 | 否(自動 gitignore) |
三層會合併。本章用專案層 .claude/settings.json,因為閘門是團隊規則。
事件與 matcher
Section titled “事件與 matcher”常用事件:PreToolUse(工具執行前,可以擋)、PostToolUse(執行後)、SessionStart、UserPromptSubmit、Stop。
matcher 對工具名做比對,可以用 | 列多個:Edit|Write 就是「Edit 或 Write 之前」。設定長這樣:
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/spec_gate.py\"" } ] } ] }}$CLAUDE_PROJECT_DIR 是 session 啟動時的專案根目錄,用它寫路徑,從子目錄啟動也找得到腳本。
腳本收到什麼、怎麼回
Section titled “腳本收到什麼、怎麼回”Claude Code 把一份 JSON 從 stdin 送給你的腳本,PreToolUse 至少有這些欄位:
{ "cwd": "/path/to/project", "hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": { "file_path": "/path/to/project/src/todo.py", "old_string": "...", "new_string": "..." }, "tool_use_id": "toolu_..."}tool_input.file_path 一律是絕對路徑(相對路徑與 ~ 都先被展開,繞不過去)。
回應靠 exit code:
exit 0:放行。stderr 只進 debug log,Claude 看不到。exit 2:擋下,stderr 的文字會送給 Claude 當理由。這是唯一靠 exit code 就能擋的數字——exit 1只是「非阻斷錯誤」,動作照做。
/hooks 檢視
Section titled “/hooks 檢視”在 claude 裡打 /hooks 會列出目前生效的所有 hook、來自哪一層設定、指令是什麼。改完 settings.json 之後用它確認有沒有讀到。
學會本章的閘門之後,這四個是最常見的變化型(官方 hooks 指南有完整範例):
| 想要 | 事件+matcher | 做法 |
|---|---|---|
| 擋掉危險指令 | PreToolUse + Bash |
檢查 tool_input.command 有沒有 rm -rf 之類的模式,有就 exit 2 |
| 改完自動 format | PostToolUse + Edit|Write |
對剛改過的檔跑 formatter;事後事件擋不了,但保證每次都跑 |
| 留稽核紀錄 | PostToolUse(不設 matcher) |
每次工具呼叫寫一行 log,之後可回查 |
| 測試沒過不准收工 | Stop |
跑測試,失敗就 exit 2 擋住回合結束;連續擋 8 次會被強制放行 |
順帶一提,官方也建議可以直接請 Claude 幫你寫 hook(「寫一個 hook,每次 Edit 之後跑 eslint」)——寫完自己讀過、重開 claude 生效。本章帶你手寫一次,是為了讓你看懂它收到什麼、怎麼回,之後請 Claude 寫你才驗得動。
實務案例:spec-gate
Section titled “實務案例:spec-gate”一個團隊的規則是「產品碼沒有已確認的規格文件不准改」。他們把它做成 PreToolUse hook:讀 tool_input.file_path,算出相對於專案根目錄的路徑,如果落在受保護的目錄而規格檔不存在,就印理由到 stderr 並 exit 2。
本章的版本把規則簡化成一句:src/ 底下沒有 SPEC.md 就不准改。腳本另一個值得學的地方:每條「放行」路徑都在 stderr 留一行 [spec-gate] skip: <原因>,不靜默吞例外——閘門壞了你才看得出來。
進階選做:hook 為什麼擋不住 shell 改檔
Section titled “進階選做:hook 為什麼擋不住 shell 改檔”matcher: "Edit|Write" 只攔 Edit 與 Write 這兩個工具。Claude 若改用 Bash 跑 sed -i 或 echo > file,走的是 Bash 工具,這個 hook 根本不會被叫到。要補的話再加一條 matcher: "Bash" 的 hook,讀 tool_input.command,對 >、sed -i、tee 這類會寫檔的指令做判斷。本章練習用提示詞先把 shell 改檔排除,是為了讓你先看清楚閘門本身。
練習裝 spec-gate,看它擋下一次 Edit
-
起點:把 repo 重置回乾淨的
main(在 repo 根目錄執行)。Terminal window git merge --abort 2>/dev/nullgit checkout -f main && git reset --hard origin/maingit clean -fd && git worktree prune白話:這段=把 repo 恢復成剛 clone 完的乾淨狀態。它會丟掉未 commit 的改動與未追蹤檔(含你自己貼進
.claude/的檔案),但不會動被 gitignore 的.todo.json(只影響list顯示的項目,想清就rm -f .todo.json)與.claude/settings.local.json(存的是你核准過的權限,留著沒關係)。 -
信任資料夾:如果這個目錄還沒接受過信任對話,先
claude接受再/exit。 -
**在終端機(不在 Claude 裡)**貼兩個檔案。先是設定(與練習 repo 的
solutions/ch4-hooks/.claude/settings.json相同,內容就是上面「事件與 matcher」那段):Terminal window cat > .claude/settings.json <<'EOF'{"hooks": {"PreToolUse": [{"matcher": "Edit|Write","hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/spec_gate.py\"" }]}]}}EOF再來是腳本(與
solutions/ch4-hooks/.claude/hooks/spec_gate.py相同):cat > .claude/hooks/spec_gate.py <<'EOF'#!/usr/bin/env python3"""PreToolUse hook:src/ 底下沒有 SPEC.md 就不准改(「spec-gate」概念版)。Claude Code 會把 JSON 送到 stdin(tool_name、tool_input.file_path、cwd)。擋下=stderr 印原因、exit 2;其他情況一律 exit 0,但每條放行/跳過路徑都先在 stderr 留一行「[spec-gate] skip: <原因>」,不靜默吞例外。"""import jsonimport osimport sysfrom pathlib import Pathfrom typing import OptionalBLOCK_MSG = "已擋下:src/ 受 SPEC 保護。請停止並回報使用者,不要自行建立 SPEC.md。"def skip(reason: str) -> int:print("[spec-gate] skip: " + reason, file=sys.stderr)return 0def find_root(start: Path) -> Optional[Path]:"""從 start 往上找第一個含 .git 或 .claude 的目錄。"""for candidate in [start] + list(start.parents):if (candidate / ".git").exists() or (candidate / ".claude").exists():return candidatereturn Nonedef read_payload() -> Optional[dict]:try:payload = json.load(sys.stdin)except (ValueError, OSError):return Nonereturn payload if isinstance(payload, dict) else Nonedef gate(payload: dict) -> int:file_path = (payload.get("tool_input") or {}).get("file_path")if not file_path:return skip("tool_input 沒有 file_path")start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd")if not start:return skip("沒有 CLAUDE_PROJECT_DIR 也沒有 cwd")root = find_root(Path(start).resolve())if root is None:return skip("往上找不到含 .git 或 .claude 的目錄")try:rel = Path(file_path).resolve().relative_to(root).as_posix()except ValueError:return skip("檔案不在專案底下:" + str(file_path))if not rel.startswith("src/"):return skip("不在 src/ 底下:" + rel)if (root / "SPEC.md").exists():return skip("SPEC.md 存在,放行 " + rel)print(BLOCK_MSG, file=sys.stderr)return 2def main() -> int:payload = read_payload()if payload is None:return skip("stdin 不是合法 JSON")return gate(payload)if __name__ == "__main__":sys.exit(main())EOF -
建完
.claude/檔案要重開:claude還開著先/exit,然後claude。開好先打/hooks,確認 PreToolUse 底下列出Edit|Write與spec_gate.py。Terminal window claude -
整段貼這個提示詞,故意觸發閘門:
只用 Edit 工具在 src/todo.py 最上面加一行註解,不要用 shell 指令改檔;若被 hook 擋下就停下來回報我,不要建任何檔案 -
看到它回報被擋之後,你自己(在終端機,不是請 Claude)建規格檔,再下一次同樣的提示詞:
Terminal window echo "改 todo.py 加註解" > SPEC.md
❯ 只用 Edit 工具在 src/todo.py 最上面加一行註解…… ⏺ Edit(src/todo.py) ⎿ Blocked by PreToolUse hook 已擋下:src/ 受 SPEC 保護。請停止並回報使用者,不要自行建立 SPEC.md。 Edit 被 hook 擋下了:src/ 受 SPEC 保護。我先停在這裡回報你,沒有改任何檔案。
重點兩件事:Edit 根本沒有執行;Claude 引用的理由就是你在腳本裡寫的那句。
- 步驟 5:Claude 回報 Edit 被 hook 擋下,並引用理由 「已擋下:src/ 受 SPEC 保護」;它沒有改任何檔、沒有建 SPEC.md。此時
git status --porcelain只有兩行:?? .claude/settings.json與?? .claude/hooks/spec_gate.py。 - 步驟 6:同樣的提示詞這次成功。
git diff src/todo.py是一行新增(檔案最上面多一行註解);git status --porcelain多了?? SPEC.md與M src/todo.py。
- 沒被擋:
/hooks看 PreToolUse 有沒有列出來。沒有=settings.json 沒讀到(JSON 格式錯、或沒重開claude)。 - 它自己建了 SPEC.md:提示詞少了「不要建任何檔案」。重置後再來一次——閘門不能被它自己滿足,這是設計重點。
- 它用 Bash 改檔繞過去了:見「進階選做」,本章刻意先不攔 Bash。
自檢(勾選會存在你的瀏覽器)0/4
以 Claude Code 2.1.246(2026-08-26)為準