详解 Claude Code Hook:把 AI Agent 的关键节点变成可编排工作流

13 分钟阅读

Claude Code 很适合让模型自己读代码、改文件、跑命令,但工程团队真正关心的往往不是“能不能自动执行”,而是“能不能在关键节点稳定地接入规则”。

比如:文件写完后自动格式化;准备执行危险命令前先拦截;Claude 等待输入时给你发通知;上下文压缩后重新注入项目约定;会话结束时记录审计日志。Hooks 的价值就在这里:它把 Claude Code 的生命周期暴露成可编排的事件点,让团队可以把确定性的工程规则接到 Agent 工作流里。

Hook 到底是什么

在 Claude Code 里,Hook 是一组配置好的处理器,会在特定事件发生时自动运行。事件可能是“用户刚提交 prompt”“工具调用前”“工具调用后”“Claude 准备停止”“配置文件发生变化”,处理器则可以是本地命令、HTTP 服务、MCP 工具、一次模型判断,或者实验性的 agent 检查。

可以把它理解成一条事件管线:

flowchart LR
    A[Claude Code 生命周期事件] --> B{matcher / if 是否命中}
    B -- 否 --> C[跳过]
    B -- 是 --> D[执行 hook handler]
    D --> E{返回结果}
    E --> F[继续执行]
    E --> G[阻止 / 修改 / 注入上下文]
    E --> H[记录日志 / 发送通知]

这和在 shell 里给 claude 包一层脚本不同。外层脚本只能看到启动和退出,Hook 能看到 Claude Code 内部更细的时机:工具调用前、工具调用后、权限弹窗出现前、上下文压缩前后、工作目录变化、文件变化、子代理启动结束等。

配置结构:事件、匹配器、处理器

Hook 配置一般写在 Claude Code 的 settings 里。最常见的项目级位置是 .claude/settings.json,用户级位置是 ~/.claude/settings.json。配置结构分三层:

  1. 顶层是 hooks
  2. 第二层按事件名分组,比如 PreToolUsePostToolUseNotification
  3. 每个事件下可以有多个 matcher group,每个 group 里再放一个或多个 hook handler。

一个典型配置长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path // empty' | xargs -r npx prettier --write"
          }
        ]
      }
    ]
  }
}

这个例子表达的是:当 Claude 成功使用 EditWrite 工具后,把 hook 输入 JSON 中的文件路径取出来交给 Prettier。它不会在 ReadBash 或其他工具后触发。

处理器类型目前可以分成五类:

类型适合场景
command本地脚本、格式化、审计、轻量校验
http把事件发到内部服务、云函数或团队共享审计系统
mcp_tool调用已经连接的 MCP server 工具
prompt让模型做一次轻量 yes/no 判断,例如“任务是否完成”
agent需要读文件、搜索代码、运行命令后再判断的复杂验证;官方标注为实验性能力

如果规则是确定性的,优先用 commandhttp。如果规则需要语义判断,再考虑 prompt。如果判断必须实际检查代码库状态,才考虑 agent,并且要注意超时和稳定性。

HTTP hook 有一个容易忽略的差异:Claude Code 会把事件 JSON 作为 POST body 发给端点,端点也用同一套 JSON 输出格式返回结果;但 HTTP 状态码本身不能表达“阻止”。非 2xx、连接失败或超时都会被当作非阻塞错误处理。要阻止工具调用或拒绝权限,端点必须返回 2xx,并在响应 JSON 里写入对应的 decisionhookSpecificOutput 字段。

生命周期:常用事件怎么选

Claude Code 的 Hook 事件很多,可以按节奏理解:

flowchart TD
    S[SessionStart / Setup] --> U[UserPromptSubmit]
    U --> L[Agentic loop]
    L --> P[PreToolUse]
    P --> R[PermissionRequest / PermissionDenied]
    R --> T[Tool 执行]
    T --> O[PostToolUse / PostToolUseFailure]
    O --> B[PostToolBatch]
    B --> L
    L --> X[Stop / StopFailure]
    X --> E[SessionEnd]
    C[ConfigChange / CwdChanged / FileChanged] -.异步事件.-> L
    K[PreCompact / PostCompact] -.上下文管理.-> L

日常最容易用到的是下面这些:

事件触发时机典型用途
SessionStart新会话、恢复会话、/clear、压缩后重新进入会话注入动态上下文、初始化环境变量
UserPromptSubmit用户 prompt 交给 Claude 前拦截敏感请求、补充上下文
PreToolUse工具调用执行前阻止危险命令、改写工具输入、自动权限决策
PermissionRequest权限弹窗即将出现时对特定工具调用自动允许或拒绝
PostToolUse工具成功执行后格式化、记录日志、根据结果补充上下文
PostToolBatch一批并行工具全部结束后、下一次模型请求前基于整批工具结果做汇总检查
NotificationClaude Code 发送通知时桌面通知、IM 通知
StopClaude 准备结束本轮回复时检查任务是否真正完成,不满足则继续
StopFailure本轮因为 API 错误结束时告警、记录失败原因
PreCompact / PostCompact上下文压缩前后压缩前阻止或记录压缩前后状态;压缩后补充上下文通常用 SessionStartcompact matcher
ConfigChangesettings、skills 等配置发生变化时审计配置变化、阻止非授权变更
CwdChanged / FileChanged工作目录或被监听文件变化时重载环境、刷新 watch paths
SessionEnd会话结束时清理临时文件、保存统计信息

这里有一个容易误判的点:PostToolUse 已经晚了,它不能撤销工具造成的副作用。 如果你要阻止 rm、拦截写入生产配置、禁止访问敏感目录,应该放在 PreToolUsePostToolUse 更适合格式化、审计、补充上下文和结果检查。

matcher 和 if:别让 Hook 到处乱跑

Hook 不是越宽越好。没有 matcher,或者 matcher 为空,通常表示在该事件的每次发生时都触发。对于高频事件,范围过宽会让 Claude Code 变慢,也会制造难排查的副作用。

matcher 的规则可以这样记:

matcher 写法含义
"""*" 或省略匹配该事件的所有发生
只包含字母、数字、_、``
包含其他字符按 JavaScript 正则表达式处理,例如 mcp__github__.*

不同事件的 matcher 匹配字段并不一样。工具事件匹配 tool_name,例如 BashEditWritemcp__github__search_repositoriesNotification 匹配通知类型;SessionStart 匹配启动来源;SessionEnd 匹配结束原因;PreCompact / PostCompact 匹配 manualauto

如果需要同时按工具名和参数过滤,用 if 字段更合适。matcher 只能先把范围缩到某类工具,if 可以进一步写成权限规则语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-push.sh"
          }
        ]
      }
    ]
  }
}

这个配置只关心 Bash 里的 git push 子命令。像 npm test && git push origin main 这类复合命令,也会因为其中一个子命令命中而触发。if 只对工具事件有效,包括 PreToolUsePostToolUsePostToolUseFailurePermissionRequestPermissionDenied;放到其他事件上不会达到过滤目的,反而会让 hook 不运行。

输入输出:stdin、stdout、stderr 和退出码

命令型 Hook 的通信模型非常 Unix:Claude Code 把事件上下文作为 JSON 写入脚本的 stdin;脚本通过 stdout、stderr 和退出码表达结果。

一个 PreToolUse 的输入大致会包含这些字段:

1
2
3
4
5
6
7
8
9
{
  "session_id": "abc123",
  "cwd": "/repo",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}

退出码语义需要特别注意:

退出码含义
0成功。Claude Code 会解析 stdout 里的 JSON 输出;多数事件的普通 stdout 只进 debug log
2阻塞型错误。通常会阻止当前动作,并把 stderr 反馈给 Claude 或用户
其他非零值多数事件下是非阻塞错误,动作继续执行,只显示 hook 错误摘要

这意味着,如果你写的是“安全策略”,不要用常规的 exit 1 期待它阻止动作。大多数事件里,exit 1 只是报告 hook 出错,Claude Code 仍会继续。要阻止工具调用、prompt 处理、上下文压缩或停止行为,通常应该使用 exit 2,或者在 exit 0 时输出结构化 JSON。

例如,一个最小的危险命令拦截脚本可以这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/usr/bin/env bash
set -euo pipefail

payload="$(cat)"
command="$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')"

if [[ "$command" =~ (^|[[:space:];&|])rm[[:space:]]+-rf[[:space:]] ]]; then
  echo "Blocked: rm -rf requires manual review." >&2
  exit 2
fi

exit 0

如果要表达更细的控制,推荐让脚本 exit 0 并输出 JSON。不要混用两套机制:Claude Code 只会在退出码为 0 时解析 JSON;如果脚本 exit 2,stdout 里的 JSON 会被忽略。

PreToolUse 的结构化拒绝可以写成:

1
2
3
4
5
6
7
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Production migration must be approved by a human operator."
  }
}

permissionDecision 常见值包括:

行为
allow跳过交互式权限提示,但不能覆盖 deny 或 ask 规则
deny取消工具调用,并把原因反馈给 Claude
ask正常询问用户
defer非交互模式下延后工具调用,适合集成方自己收集审批结果后恢复

这里的边界也很关键:Hook 可以收紧限制,但不能绕过更高优先级的 deny 规则。即使 hook 返回 allow,如果 settings 或组织级策略里有 deny,工具调用仍会被拒绝。

JSON 输出:阻止、停止、注入上下文

结构化 JSON 输出比退出码更适合做可维护的策略。除了 PreToolUsehookSpecificOutput,还有几类常见输出字段:

字段作用
continue: false无论事件类型如何,停止后续处理
stopReason配合 continue: false,给用户看的停止原因
systemMessage给用户展示警告信息
terminalSequence让 Claude Code 代为发出受限的终端通知序列
decision: "block" + reason部分事件使用的顶层阻止语义
hookSpecificOutput.additionalContext把动态上下文注入 Claude 的上下文窗口

比如在 PostToolUse 后告诉 Claude“刚编辑的是生成文件,不应该直接改”,可以返回:

1
2
3
4
5
6
{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "This file is generated. Edit src/schema.ts and run bun generate instead."
  }
}

additionalContext 很有用,但不要把它当成第二份 CLAUDE.md。静态规则应该写进 CLAUDE.md,动态信息才适合通过 Hook 注入,例如当前分支、部署环境、刚刚查询到的 CI 状态、某个目录的临时只读规则。并且最好写成事实描述,而不是伪装成系统指令,否则可能触发 Claude 的提示注入防护。

多个 Hook 同时命中时怎么合并

同一个事件可以命中多个 hook。Claude Code 会执行所有匹配的处理器,然后合并结果。

最典型的是 PreToolUse:如果多个 hook 分别返回 allowaskdeny,最终以最严格的结果为准,deny 优先于 askask 优先于 allow。这让团队可以把“审计日志”和“安全阻断”拆开写:日志 hook 负责记录,安全 hook 负责拒绝;即使安全 hook 拒绝了命令,日志 hook 也可以已经完成记录。

但并发也带来一个坑:如果多个 PreToolUse hook 都返回 updatedInput 试图改写同一个工具参数,最后谁生效取决于哪个 hook 更晚完成,顺序并不稳定。工程上应避免多个 hook 同时改写同一份输入,把改写逻辑合并到一个脚本里会更可控。

几个高价值实践场景

1. 编辑后自动格式化

文件写入后自动格式化是最适合 PostToolUse 的场景。它不阻止 Claude 写文件,只在写完后把格式拉回项目标准:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "file=$(jq -r '.tool_input.file_path // empty'); case \"$file\" in *.ts|*.tsx|*.js|*.jsx) npx prettier --write \"$file\" ;; esac"
          }
        ]
      }
    ]
  }
}

如果 Claude 通过 Bash 间接修改文件,例如运行代码生成器,这个 hook 看不到具体文件。需要更强覆盖时,可以配合 Stop 在每轮结束前扫描 git status --porcelain,或者额外监听 Bash 并由脚本自行计算变更文件。

2. 执行前阻止危险命令

安全策略应该尽量放在 PreToolUse。比如阻止生产库迁移:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(*migrate*)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-migration.sh"
          }
        ]
      }
    ]
  }
}

脚本里应该读取 stdin JSON,而不是只靠字符串包含判断。至少要检查当前目录、命令内容、环境变量和目标数据库标识。只要发现可能触达生产环境,就用 exit 2 或 JSON permissionDecision: "deny" 阻断。

3. Claude 等待你时发送通知

Notification 适合把 Claude Code 的状态接到桌面通知、企业 IM 或手机推送。它不能阻止行为,主要负责副作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

这里 matcher 只监听 permission_promptidle_prompt,避免认证成功、MCP elicitation 等其他通知也触发同一套提醒。

4. 上下文压缩后重新注入关键信息

上下文压缩会把长会话压成摘要,可能丢掉一些项目级细节。可以用 SessionStartcompact matcher,在压缩后重新注入动态上下文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-context.sh"
          }
        ]
      }
    ]
  }
}

SessionStart 的 stdout 会作为上下文提供给 Claude。注意这里适合注入“当前事实”,例如当前分支、活跃任务、测试命令,而不是长篇规则。固定规则仍然应该沉到 CLAUDE.md

5. 用 Stop Hook 检查任务是否真的完成

Stop 在 Claude 准备结束本轮回复时触发。它可以阻止 Claude 停止,让 Claude 继续工作。适合“必须满足某个完成条件”的场景,例如测试必须通过、lint 必须通过、所有 TODO 必须完成。

Stop 不是“任务完成事件”。它在 Claude 每次准备结束回复时都会触发,不会在用户手动中断时触发;如果本轮因为 API 错误结束,触发的是 StopFailure。写 Stop hook 时也要避免无限阻塞,官方输入里提供了 stop_hook_active 用于判断当前是否已经处在 hook 触发的继续流程中;Claude Code 也会在连续阻塞多次后结束这一轮。

用 prompt hook 可以做轻量判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check whether the user's requested task is complete. Return JSON only: {\"ok\": true} or {\"ok\": false, \"reason\": \"what remains\"}."
          }
        ]
      }
    ]
  }
}

如果判断必须运行测试或读取代码,用 agent hook 更合适,但它仍是实验性能力。更稳定的做法是把确定性检查放到 command hook 里,例如直接运行 npm test,失败时 exit 2 并把错误摘要写到 stderr。

安全边界:Hook 很强,也很危险

Hook 的权限来自当前用户。命令型 Hook 可以读取、修改、删除当前用户能访问的文件,也可以执行网络请求或调用本地凭据。因此,Hook 不应该被当作“无害配置”看待。

实践上至少遵守这些规则:

  1. Hook 脚本使用绝对路径,优先放进项目的 .claude/hooks/ 并接受代码审查。
  2. 所有从 stdin 读到的路径、命令、参数都视为不可信输入。
  3. Shell 变量全部加引号,避免空格、换行和 glob 展开造成意外。
  4. 对文件路径做路径穿越检查,拒绝 ...git/.env、密钥目录等敏感目标。
  5. stdout 如果要输出 JSON,就保持纯 JSON;日志写 stderr 或 debug log。
  6. 能用 matcherif 缩小范围,就不要让脚本在每次事件上都跑。
  7. 对会阻塞 Claude 的 hook 设置合理 timeout,避免把交互卡死。

对于耗时任务,可以给命令型 hook 设置 async: true 让它后台运行;但异步 hook 不能阻止或控制 Claude 的当前动作,decisionpermissionDecisioncontinue 这类返回字段不会产生同步控制效果。因此安全拦截、权限决策和“必须通过才继续”的检查不应该异步化。

还有一个权限模式相关的细节:PreToolUse 在权限模式检查前触发。即使用户使用了 --dangerously-skip-permissions 或 bypass permissions,只要 hook 返回 deny,工具调用仍然会被拦截。反过来,hook 返回 allow 也不能突破 deny 规则。这是 Hooks 能作为团队安全护栏的关键原因。

调试:先看 /hooks,再看 debug log

配置好之后,先在 Claude Code 里运行 /hooks。这个界面是只读的,但可以看到每个事件下配置了多少 hook、matcher 是什么、配置来自哪个文件、实际命令或 URL 是什么。它很适合排查“我以为配置了,但其实没有加载”的问题。

如果 hook 没触发,优先检查四件事:

  1. 配置文件位置是否正确:用户级、项目级、本地级或组织托管配置。
  2. 事件类型是否选错:阻止工具调用要用 PreToolUse,执行后处理才用 PostToolUse
  3. matcher 是否大小写一致,工具名是否真实匹配。
  4. 使用了 if 时,是否放在了支持 if 的工具事件上。

如果 hook 触发了但行为不对,用 claude --debugclaude --debug-file <path> 看执行细节。debug log 会记录哪些 hook 匹配、退出码、stdout 和 stderr。需要更细的匹配过程时,可以设置 CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose

我的使用建议

Claude Code Hooks 最适合承载“确定性、可审计、可重复”的工程规则,而不是把所有决策都交给另一个模型。

我会按这个顺序设计:

  1. 先写静态规则:长期不变的项目规范放 CLAUDE.md
  2. 再写确定性 Hook:格式化、审计、安全阻断、环境加载,用 commandhttp
  3. 最后才用模型判断:只有规则无法用脚本清晰表达时,再引入 promptagent

更具体地说:想阻止副作用,用 PreToolUse;想处理结果,用 PostToolUse;想检查本轮是否完成,用 Stop;想通知人,用 Notification;想补动态上下文,用 SessionStartadditionalContext

Hook 不是为了让 Claude Code “更自动”这么简单,它真正解决的是 Agent 工程化里的控制点问题。把这些控制点设计好,AI Agent 才能从一个强大的交互工具,变成可以接入团队流程的工程系统。

参考资料