Skip to content

第 14 章 Hooks 系统

Hooks 是把工程纪律编码成自动执行的生命周期钩子——让低风险动作自动跑、高风险动作必停。权限规则(第 05 章)负责"谁能做",Hooks 负责"做完前后会发生什么"。

14.1 结论:Hooks 是工程纪律的可执行化

第 06 章讲可执行闭环时强调:把"完成"变成机器可检查的状态。Hooks 就是把这条闭环焊到 Claude Code 生命周期上的那道焊缝——它让你在关键节点(用户提交 prompt、工具调用前后、会话结束)插入自己的逻辑,无需 Claude 同意、无需 Claude 知道

这与让 Claude"自己跑测试"有本质区别:Claude 跑测试是模型决策,会忘、会跳过、会被 prompt 注入绕过;Hook 是 harness 层执行,模型无法绕过。第 05 章的权限规则决定"动作允不允许",第 14 章的 Hook 决定"动作前后再追加什么"。

典型落地:写完文件自动 prettier 格式化、commit 前强制跑测试、敏感文件被改时告警、Claude 停下时桌面通知。这四件事一旦写进 settings.json,就不再依赖 Claude 的"自觉"。

核对日期:2026-07-08,Claude Code v2.1.202+。官方文档 https://code.claude.com/docs/en/hooks。

14.2 五种 Hook 类型对比

Hooks 已从早期的"shell 命令"单一形态,扩展为五种类型,各有适用场景:

类型机制适用场景默认超时
command本地 shell 命令(stdin 吃 JSON)格式化、lint、测试、本地脚本600s
httpPOST 到 URL远程审计、企业网关、CI 触发600s
mcp_tool调 MCP 服务器上的工具复用 MCP 生态(security_scan 等)600s
prompt单轮 LLM 评估(默认 Haiku)语义判断("测试都跑了吗")30s
agent (实验性)带 Read/Grep/Glob 的 subagent需要读文件验证的复杂条件60s

为什么从 command 扩展到后四种?因为不是所有判断都该用 shell 写:

  • http 让企业能把所有工具调用送到中心审计服务,无需在每台机器上部署脚本;
  • mcp_tool 让你复用已经接好的 MCP 服务器(例如 security_scan),不重复造轮子;
  • prompt 解决"判断需要语义理解"的场景——例如 Stop 前让 LLM 评估"所有任务真的完成了吗",shell 写不出这种判断;
  • agent 进一步,让验证者能读文件、跑 grep,例如 commit 前检查是否引入了新的 console.log

实验性标注:agent 类型官方明确标注 experimental,行为可能变。生产环境优先用 command

类型支持矩阵(重要,不是所有事件都支持所有类型):

  • 全五类支持:PreToolUse、PostToolUse、PostToolUseFailure、PostToolBatch、UserPromptSubmit、UserPromptExpansion、Stop、SubagentStop、PermissionRequest、PermissionDenied、TaskCreated、TaskCompleted、TeammateIdle
  • 仅 command/http/mcp_tool:ConfigChange、CwdChanged、FileChanged、Elicitation、ElicitationResult、InstructionsLoaded、Notification、PostCompact、PreCompact、SessionEnd、StopFailure、SubagentStart、WorktreeCreate、WorktreeRemove
  • 仅 command/mcp_tool:SessionStart、Setup

SessionStart 不支持 http/prompt/agent,因为启动时太早,网络和 LLM 都可能没就绪。

14.3 事件全览(按生命周期分组)

官方已扩展到 30+ 事件。按触发节奏分四组:

14.3.1 会话级(一次会话触发一次或几次)

事件触发能否阻断
SessionStart会话开始或恢复否,只能注入 context
SessionEnd会话结束否,清理用
Setup--init-only / -p --init / -p --maintenance
InstructionsLoadedCLAUDE.md 或 .claude/rules/*.md 被加载否,审计用
ConfigChange配置文件变化是(但 policy_settings 不可阻断)
CwdChanged工作目录变化(如 cd)
FileChanged被 watch 的文件变化
PreCompact / PostCompact上下文压缩前后Pre 可阻断,Post 不可
WorktreeCreate / WorktreeRemoveworktree 创建/删除Create 任何非零退出即失败

14.3.2 每轮(每个用户 prompt 触发)

事件触发能否阻断
UserPromptSubmit用户提交 prompt,Claude 处理前是,擦除 prompt
UserPromptExpansion/skill 类命令展开前是,阻止展开
StopClaude 响应完成是,强制继续
StopFailureAPI 错误导致中断否,输出被忽略
MessageDisplay助手消息流式渲染时否,只改显示不改 transcript
NotificationClaude Code 发通知(权限提示、空闲等)否,side effect 用

14.3.3 工具循环(每个工具调用触发)

事件触发能否阻断
PreToolUse工具执行前是,allow/deny/ask/defer
PermissionRequest权限对话框弹出前是,allow/deny
PermissionDeniedauto 模式分类器拒绝否,只能 retry: true 让模型重试
PostToolUse工具成功后否(工具已跑),可注入反馈
PostToolUseFailure工具失败后否,可注入纠正反馈
PostToolBatch一批并行工具调用全部完成后是,阻断下一轮模型调用

14.3.4 Subagent 与团队

事件触发能否阻断
SubagentStartsubagent 启动否,可注入 context
SubagentStopsubagent 完成是,强制继续
TaskCreated / TaskCompletedAgent Teams 任务创建/完成
TeammateIdle团队成员即将闲置是,强制继续工作
Elicitation / ElicitationResultMCP server 请求用户输入

14.3.5 重点关注四个事件

PreToolUse:用得最多。在工具执行前拦截,可以 allow/deny/ask/defer,还能改写工具参数(updatedInput)。是"敏感文件修改必停"的实现位置。

PostToolUse:工具成功后触发。是"格式化、lint、测试"的实现位置。注意它无法阻止已发生的动作——要阻止必须在 PreToolUse。

Stop:Claude 停下时触发。用 decision: "block" 让它继续工作。内置 /goal 命令本质就是会话级 prompt-based Stop hook。注意 8 次连续阻断后 Claude Code 强制结束。

UserPromptSubmit:用户提交 prompt 时触发。可以注入 context(如当前 git 分支、最近 CI 结果),也可以阻断(如检测到 prompt 里含密钥)。

14.4 配置示例:四个可直接跑的 Hook

配置位置(优先级从高到低):Managed policy > ~/.claude/settings.json > .claude/settings.json > .claude/settings.local.json > Plugin hooks/hooks.json > Skill/agent frontmatter。后定义的不覆盖前定义的,而是全部并行执行,相同 handler 自动去重。

14.4.1 示例 1:PostToolUse 自动 prettier 格式化

目标:Claude 每次 Edit/Write 文件后,自动跑 prettier 格式化该文件。

.claude/hooks/format.sh:

bash
#!/bin/bash
# 读取 stdin 的 JSON,提取文件路径,跑 prettier
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE_PATH" ] && exit 0
# 只处理 .ts/.tsx/.js/.jsx/.json/.css
case "$FILE_PATH" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css) ;;
  *) exit 0 ;;
esac
npx prettier --write "$FILE_PATH" 2>&1 || exit 0
# 通过 additionalContext 告诉 Claude 已格式化
jq -nc --arg f "$FILE_PATH" \
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:("Formatted " + $f)}}'

.claude/settings.json:

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
  • 命令:chmod +x .claude/hooks/format.sh,然后让 Claude 编辑任意 .ts 文件。
  • 预期输出:终端显示 Formatted /path/to/file.ts,文件被 prettier 重排。
  • 验证:编辑后立刻 git diff 看到 prettier 风格的变更。
  • 失败边界:prettier 不存在时 npx prettier 失败,脚本 exit 0 静默跳过——这是故意的,避免 hook 报错阻塞工作流。若需强提示,改成 exit 2 让 Claude 看到 stderr。注意 additionalContext 上限 10000 字符,超长会被写文件并返回路径。

14.4.2 示例 2:PreToolUse 在 git commit 前强制跑测试

目标:Claude 调 git commit 时,如果测试没过就阻止。

.claude/hooks/prevent-bad-commit.sh:

bash
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
# 只拦截 git commit
if ! echo "$COMMAND" | grep -qE '^git commit'; then
  exit 0
fi
# 跑测试
if ! npm test 2>/tmp/test-fail.log; then
  jq -nc --arg msg "Tests failed, commit blocked: $(head -20 /tmp/test-fail.log)" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$msg}}'
else
  exit 0
fi

.claude/settings.json(节选):

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/prevent-bad-commit.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

更简洁的写法是用 if 字段做初筛,避免每次 Bash 调用都启动脚本:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit*)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/prevent-bad-commit.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

if 用 permission rule 语法,只有 Bash 子命令匹配 git commit* 时脚本才启动,其他 Bash 调用零开销。

  • 命令:让 Claude 跑 git commit -m "..."
  • 预期输出:测试失败时,Claude 收到 deny 决策和失败日志,转去修测试;测试通过时正常 commit。
  • 验证:claude --debug-file /tmp/cc.log 后查看日志,能看到 hook 执行记录。
  • 失败边界:测试套件本身有副作用(写数据库)时会每次 commit 都触发——别在这种项目用。if 是 best-effort 过滤,复杂管道(如 npm test && git commit)仍会触发,因为 if 检测到子命令匹配。硬性约束请用权限规则(第 05 章),不要靠 hook。

14.4.3 示例 3:敏感文件修改告警

目标:任何对 .env、密钥、CI 配置的修改,终端响铃并桌面通知。

.claude/hooks/alert-sensitive.sh:

bash
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
  *.env|.env*|*secrets*|*.pem|*id_rsa*|.github/workflows/*.yml) ;;
  *) exit 0 ;;
esac
# 桌面通知(OSC 777 兼容 urxvt/Ghostty/Warp/iTerm2)
SEQ=$(printf '\033]777;notify;Claude Code;Sensitive file modified: %s\007' "$FILE_PATH")
jq -nc --arg seq "$SEQ" '{terminalSequence:$seq,systemMessage:"Sensitive file modified: '"$FILE_PATH"'"}'

.claude/settings.json(节选):

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/alert-sensitive.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
  • 命令:让 Claude 编辑 .env
  • 预期输出:终端响铃,桌面弹通知,会话内显示 Sensitive file modified: /path/.env
  • 验证:在 iTerm2/Ghostty/Warp 下应看到系统通知。
  • 失败边界:terminalSequence 是 v2.1.141+ 的字段,之前的做法是直接写 /dev/tty,但 v2.1.139 起 hook 跑在独立 session 里没有控制终端,/dev/tty 失效。terminalSequence 只允许 OSC 0/1/2/9/99/777 和 BEL,其他转义被忽略,所以无法用它改颜色或移动光标。注意这只告警不阻断——要阻断把脚本结尾改成 permissionDecision: "ask" 让用户确认。

14.4.4 示例 4:Stop 时桌面通知

目标:Claude 完成响应回到空闲状态时,通知用户回来审查。

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'SEQ=$(printf \"\\033]777;notify;Claude Code;Claude is done\\007\"); jq -nc --arg seq \"$SEQ\" \"{terminalSequence:\\$seq}\"'",
            "args": []
          }
        ]
      }
    ]
  }
}
  • 命令:让 Claude 跑一个长任务(如重构),切到别的窗口工作。
  • 预期输出:Claude 停下时桌面弹通知"Claude is done"。
  • 验证:切换窗口能看到系统通知中心有记录。
  • 失败边界:Stop hook 若返回 decision: "block" 会强制 Claude 继续——本例没返回 decision 所以不阻塞。连续 8 次 block 后 Claude Code 强制结束,防止死循环。stop_hook_active 字段为 true 时说明已是阻断后的续跑,应避免再次 block 同一条件。

14.5 PreToolUse 决策控制:allow/deny/ask/defer

PreToolUse 是唯一用 hookSpecificOutput.permissionDecision 而非顶层 decision 的事件,因为它需要比"block/不 block"更细的控制:

效果仍会评估 deny/ask 规则?
allow跳过权限提示,直接执行
deny阻止工具调用,原因给 Claude
ask弹权限提示让用户确认
defer退出,Claude Code 进程退出码带 stop_reason: "tool_deferred",留给外层 SDK 接管

多 hook 冲突时优先级:deny > defer > ask > allow。只要有一个 deny,就 deny。

updatedInput 可以在 allow/ask 时改写工具参数。典型用法:PreToolUse 拦截 AskUserQuestion,通过自己的 UI 收集答案,然后用 updatedInput.answers 返回,跳过终端交互——这是 Agent SDK 应用接管交互的关键机制。

defer 只在非交互模式(claude -p)生效,交互模式会被忽略并打 warning。它要求本轮只有单次工具调用,批量调用时 defer 失效。

弃用提醒:PreToolUse 早期用顶层 decision: "approve"/"block",已弃用,映射到 allow/deny。新代码用 hookSpecificOutput.permissionDecision。其他事件(PostToolUse、Stop 等)仍用顶层 decision: "block"

14.6 适合与不适合

适合交给 Hook

  • 格式化/lint:PostToolUse 上挂 prettier、eslint --fix,无需 Claude 记得
  • 校验/测试:PreToolUse 上挂"commit 前跑测试""push 前跑安全扫描"
  • 告警:敏感文件修改、生产环境操作、密钥外泄
  • 自动注入 context:SessionStart 注入当前分支、最近 CI 结果;UserPromptSubmit 注入相关 issue
  • 环境管理:CwdChanged 上跑 direnv 逻辑,FileChanged 上重载 .envrc
  • 审计:ConfigChange、InstructionsLoaded 做合规日志
  • 非交互场景的交互接管:Agent SDK 用 defer 把 AskUserQuestion 抛回自己的 UI

不适合交给 Hook

  • 复杂逻辑分支:Hook 应短、快、确定。如果你需要"如果 A 则这样,否则如果 B 则那样"的多层判断——那是 Skill 的活,让 Claude 调 skill。
  • 需要 Claude 理解语义的判断:用 prompt 类型 hook 而非 command 写正则。但即便如此,判断"代码是否正确"不该靠 hook,该靠 /code-review/verify
  • 生产环境操作:红线,见第 05 章。Hook 跑在你用户权限下,能删库——别在 hook 里放 rm -rf
  • 替代人类审查:Hook 是自动化的第一道闸,不是最后一道。git diff 人工审查永远是终点(第 06 章 6.2 节步骤 7)。
  • 跨工具调用的状态机:Hook 之间无共享状态(每次都是新进程)。要状态就用文件或 MCP server。

14.7 失败边界

14.7.1 Hook 报错阻塞工作流

Exit 2 是阻断信号,但不同事件行为不同。PostToolUse 退出 2 只是给 Claude 看 stderr,工具已经跑过了——这时 hook 报错不会回滚已发生的写操作。要阻止写必须在 PreToolUse。

UserPromptSubmit 默认超时只有 30 秒(其他事件 600 秒),因为它每轮 prompt 都跑。超时的 hook 在 v2.1.196+ 会显示通知,之前版本静默丢弃。如果你的 hook 慢,显式设 timeout

SessionEnd 默认 1.5 秒超时,因为会话要尽快退出。要更长就设 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 环境变量(最大 60 秒)。

14.7.2 异步 hook 时序问题

async: true 让 hook 后台跑,Claude 立刻继续。但 async hook 的输出在下一轮对话才送达——如果会话空闲,输出就一直等。这意味着:

  • 异步测试结果不会立刻影响当前工具调用,只会在 Claude 下一轮被读到
  • 会话结束时未完成的 async hook 会被丢弃
  • async hook 不能返回 decision/permissionDecision——动作已经发生了

需要"后台跑完立刻叫醒 Claude"用 asyncRewake: true,它在 exit 2 时立刻唤醒 Claude 并把 stderr 作为 system reminder 注入。

14.7.3 过度依赖 hook 替代审查

这是最大的陷阱。Hook 写多了,容易产生"我有 hook 兜底,不用细看 diff"的错觉。实际上:

  • Hook 是确定性规则,无法判断业务正确性
  • 正则匹配有漏网(if 过滤是 best-effort,官方明确说"用权限系统而非 hook 做硬性约束")
  • hook 本身有 bug 时会静默失败(exit 1 是 non-blocking,Claude Code 照常进行)
  • prompt/agent hook 依赖 LLM,会误判

正确姿态:hook 做机械重复工作(格式化、测试、告警),人类做语义审查(git diff + /code-review)。两者不可互替。

14.7.4 安全红线

官方明确:command hook 跑在你用户的全权限下,能改/删任何你能改/删的文件。因此:

  • 永远用 ${CLAUDE_PROJECT_DIR} 而非拼字符串
  • 永远双引号包裹变量:"$VAR" 而非 $VAR
  • 校验路径不含 ..
  • 跳过 .env.git/、密钥文件
  • 测试所有 hook 命令再加进配置
  • 别在 hook 里放破坏性命令(rm -rfgit push --force)

企业可用 allowManagedHooksOnly 禁用用户/项目/插件 hook,只允许管理员批准的 managed hook。插件强制启用的 hook 可通过 enabledPlugins 分发。

14.8 与第 05 章权限、第 16 章 Skills 的分工

三者容易混淆,边界如下:

机制层级回答的问题谁触发
权限规则(第 05 章)harness,工具调用前"这个动作允许吗?"harness 自动
Hooks(第 14 章)harness,生命周期节点"动作前后再做什么?"harness 自动
Skills(第 16 章)模型,Claude 主动调"这个任务怎么做?"Claude 决策

关键区别:

  • 权限规则是布尔门(allow/deny/ask),无副作用,无状态
  • Hook 是带副作用的钩子,可以跑命令、发请求、注入 context,但模型无法直接调用
  • Skill 是Claude 可调用的能力包,模型决定何时用,可以包含复杂多步逻辑

举例区分:

  • "禁止 git push --force"→ 权限规则(deny)
  • "commit 前跑测试"→ Hook(PreToolUse on Bash git commit)
  • "按团队规范写 commit message"→ Skill(/commit bundled skill)

混淆常见错:把"格式化代码"写成 skill 让 Claude 调。后果是 Claude 忘了调就不格式化。正确做法是 PostToolUse hook,Claude 没法绕过。

反过来:把"审查 PR"写成 hook。后果是 hook 写不出审查逻辑,只能跑 lint。正确做法是 /code-review skill,让 Claude 用模型能力审查 diff。

三层防御:权限规则挡住不该做的 → Hook 自动做该做的机械动作 → Skill 让 Claude 按规范做需要语义判断的任务。三者各司其职,缺一不可。

14.9 调试与排查

  • /hooks:只读浏览所有已配置 hook,显示来源(User/Project/Local/Plugin/Session/Built-in)
  • claude --debug:写日志到 ~/.claude/debug/<session-id>.txt
  • claude --debug-file /path/to/log:指定日志路径
  • CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose:看 hook matcher 计数和匹配细节
  • disableAllHooks: true:临时关掉所有 hook(不影响 managed hook)
  • shell profile 启动时打印文本会污染 hook 的 JSON 解析——hook 的 stdout 必须只有 JSON

常见坑:

  1. Hook 不触发:matcher 写错。Edit|Write 是精确匹配,Edit.* 是正则。mcp__memory 不会匹配任何工具(精确匹配路径),要 mcp__memory__.*
  2. JSON 解析失败:hook 脚本打印了非 JSON 文本(如 shell 启动 banner)。用 jq -nc 构造 JSON
  3. Stop hook 死循环:没检查 stop_hook_active,反复 block 同一条件。Claude Code 在 8 次连续 block 后强制结束
  4. /dev/tty 失效:v2.1.139 起 hook 无控制终端。改用 terminalSequence
  5. SessionStart hook 拿不到 MCP:SessionStart 在 MCP 连接前触发。需要 MCP 的逻辑放到 PreToolUse 或更晚

下一章:Skills 与自定义命令


下一章:MCP 服务器 | 返回目录