跳到內容

第 4 章 hooks

一句話:hook 是掛在固定時機的程式閘門——提示詞是「請它不要」,hook 是「它做不到」。

Claude 要用 Edit改 src/todo.py閘門spec_gate.py執行前放行(exit 0)SPEC.md 存在擋下(exit 2)stderr 的理由會交給 Claude
提示詞是「請它不要」,hook 是「它做不到」:Edit 執行前一刻先過閘門,exit 2 就是擋下。

Hook 是 Claude Code 在特定時機一定會執行的程式:工具呼叫前、工具呼叫後、session 開始、你送出提示詞時……你可以掛一支 shell 指令或腳本上去,它讀到當下的資料(例如要改哪個檔),決定放行還是擋下。 它跟提示詞、CLAUDE.md 最大的差別是確定性。「src/ 底下沒有規格文件就不准改」這種規則寫進 CLAUDE.md 只是建議;寫成 PreToolUse hook 就是閘門——Claude 要 Edit 的那一刻被擋下,還會收到你寫的理由。 本章要你裝一個簡化版的 spec-gate,然後故意觸發它,看它把一次 Edit 擋下來。

你送出提示詞Claude 決定要用 EditPreToolUse 閘門spec_gate.pyEdit 執行PostToolUse(執行後)exit 0 放行exit 2 擋下:Edit 不會執行,stderr 的理由回給 Claude
閘門卡在「決定用工具」與「工具真正執行」之間——exit 2 的話,工具根本不會跑。

拿本章的 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 要的是能用程式判斷的條件。

檔案 範圍 進 git?
~/.claude/settings.json 你所有專案
.claude/settings.json 這個專案 是,團隊共用
.claude/settings.local.json 這個專案、只有你 否(自動 gitignore)

三層會合併。本章用專案層 .claude/settings.json,因為閘門是團隊規則。

常用事件:PreToolUse(工具執行前,可以擋)、PostToolUse(執行後)、SessionStartUserPromptSubmitStopmatcher 對工具名做比對,可以用 | 列多個: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 啟動時的專案根目錄,用它寫路徑,從子目錄啟動也找得到腳本。

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 只是「非阻斷錯誤」,動作照做。

claude 裡打 /hooks 會列出目前生效的所有 hook、來自哪一層設定、指令是什麼。改完 settings.json 之後用它確認有沒有讀到。

學會本章的閘門之後,這四個是最常見的變化型(官方 hooks 指南有完整範例):

想要 事件+matcher 做法
擋掉危險指令 PreToolUseBash 檢查 tool_input.command 有沒有 rm -rf 之類的模式,有就 exit 2
改完自動 format PostToolUseEdit|Write 對剛改過的檔跑 formatter;事後事件擋不了,但保證每次都跑
留稽核紀錄 PostToolUse(不設 matcher) 每次工具呼叫寫一行 log,之後可回查
測試沒過不准收工 Stop 跑測試,失敗就 exit 2 擋住回合結束;連續擋 8 次會被強制放行

順帶一提,官方也建議可以直接請 Claude 幫你寫 hook(「寫一個 hook,每次 Edit 之後跑 eslint」)——寫完自己讀過、重開 claude 生效。本章帶你手寫一次,是為了讓你看懂它收到什麼、怎麼回,之後請 Claude 寫你才驗得動。

一個團隊的規則是「產品碼沒有已確認的規格文件不准改」。他們把它做成 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 -iecho > file,走的是 Bash 工具,這個 hook 根本不會被叫到。要補的話再加一條 matcher: "Bash" 的 hook,讀 tool_input.command,對 >sed -itee 這類會寫檔的指令做判斷。本章練習用提示詞先把 shell 改檔排除,是為了讓你先看清楚閘門本身。

練習裝 spec-gate,看它擋下一次 Edit

  1. 起點:把 repo 重置回乾淨的 main(在 repo 根目錄執行)。

    Terminal window
    git merge --abort 2>/dev/null
    git checkout -f main && git reset --hard origin/main
    git clean -fd && git worktree prune

    白話:這段=把 repo 恢復成剛 clone 完的乾淨狀態。它會丟掉未 commit 的改動與未追蹤檔(含你自己貼進.claude/ 的檔案),但不會動被 gitignore 的.todo.json(只影響 list 顯示的項目,想清就 rm -f .todo.json)與.claude/settings.local.json(存的是你核准過的權限,留著沒關係)。

  2. 信任資料夾:如果這個目錄還沒接受過信任對話,先 claude 接受再 /exit

  3. **在終端機(不在 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 json
    import os
    import sys
    from pathlib import Path
    from typing import Optional
    BLOCK_MSG = "已擋下:src/ 受 SPEC 保護。請停止並回報使用者,不要自行建立 SPEC.md。"
    def skip(reason: str) -> int:
    print("[spec-gate] skip: " + reason, file=sys.stderr)
    return 0
    def 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 candidate
    return None
    def read_payload() -> Optional[dict]:
    try:
    payload = json.load(sys.stdin)
    except (ValueError, OSError):
    return None
    return payload if isinstance(payload, dict) else None
    def 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 2
    def main() -> int:
    payload = read_payload()
    if payload is None:
    return skip("stdin 不是合法 JSON")
    return gate(payload)
    if __name__ == "__main__":
    sys.exit(main())
    EOF
  4. 建完 .claude/ 檔案要重開:claude 還開著先 /exit,然後 claude。開好先打 /hooks,確認 PreToolUse 底下列出 Edit|Writespec_gate.py

    Terminal window
    claude
  5. 整段貼這個提示詞,故意觸發閘門:

    只用 Edit 工具在 src/todo.py 最上面加一行註解,不要用 shell 指令改檔;若被 hook 擋下就停下來回報我,不要建任何檔案
  6. 看到它回報被擋之後,你自己(在終端機,不是請 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)為準