Claude Code 源码分析第 10 课 · 第 05
第10课

Hook 系统

27 个事件 · 5 种命令类型 · 6 个配置源 · 即发即忘阻止

什么是钩子?

挂钩允许您在 Claude Code 生命周期中精确定义的点注入副作用 - 在工具运行之前、采样完成之后、会话开始时、文件更改时。 每个钩子都是一个 匹配器+命令 对存储在设置文件中(或会话挂钩的内存中)。 当事件触发时,Claude Code 找到每个匹配的钩子,运行所有钩子,然后解释退出代码和标准输出以决定是继续、阻止还是将输出反馈给模型。

钩子是主要的延伸表面。 它们支持 lint-on-save 行为、策略执行、可观察性管道、结构化验证代理等,所有这些都无需修改 Claude Code 本身。

好奇者的切入点: 完整的钩子事件列表位于 src/entrypoints/sdk/coreTypes.ts 作为 HOOK_EVENTS 常量数组。 Hook的执行逻辑在 src/utils/hooks.ts 和个人 exec*Hook.ts 文件下 src/utils/hooks/。 配置读取自 src/utils/hooks/hooksConfigSnapshot.ts.

所有 27 个挂钩事件

事件按类别分组。 每个徽章都会显示事件名称 settings.json 以及 TypeScript 源代码中。

生命周期(会话边界)

SessionStart
SessionEnd
Setup
Stop
StopFailure

工具执行

PreToolUse
PostToolUse
PostToolUseFailure

代理及分代理

SubagentStart
SubagentStop

Compaction

PreCompact
PostCompact

许可与政策

PermissionRequest
PermissionDenied
UserPromptSubmit
ConfigChange
InstructionsLoaded

协作/多代理

TeammateIdle
TaskCreated
TaskCompleted
Notification

文件系统和环境

CwdChanged
FileChanged
WorktreeCreate
WorktreeRemove

MCP 启发

Elicitation
ElicitationResult

钩子如何发射

下图展示了单个指令的执行路径 PreToolUse 事件。 其他事件遵循相同的形状:收集事件的钩子→按顺序运行匹配器→聚合结果→决定。

flowchart TD A([Tool call requested]) --> B{Any hooks for\nPreToolUse?} B -- No --> Z([Tool executes normally]) B -- Yes --> C[Filter by matcher\ne.g. tool_name = Write] C --> D[Run each matched hook\ncommand / prompt / agent / http / function] D --> E{All hooks passed?} E -- "exit 0" --> Z E -- "exit 2 → blocking" --> F([stderr shown to model\ntool call BLOCKED]) E -- "other → non-blocking" --> G([stderr shown to USER\ntool call continues]) E -- "function hook false" --> F style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style Z fill:#0a2a1a,stroke:#6e9468,color:#b8b0a4 style F fill:#221714,stroke:#c47a50,color:#b8b0a4 style G fill:#211b14,stroke:#b8965e,color:#b8b0a4

退出代码2/非零分割是中心设计:退出2是模型可见阻塞; 其他非零是用户可见的噪声。

退出代码语义

每个事件定义其自己的退出代码语义。 以下是跨事件摘要。 阅读每个事件的完整描述 hooksConfigManager.ts → getHookEventMetadata().

PreToolUse 退出代码
退出代码Effect
0未显示标准输出/标准错误; 工具收益
2显示给模型的 stderr; 工具调用被阻止
other仅向用户显示 stderr; 工具收益
PostTool使用退出代码
退出代码Effect
0以转录模式显示的标准输出 (Ctrl+O)
2stderr 立即显示给模型
otherstderr 仅显示给用户
停止退出代码
退出代码Effect
0未显示标准输出/标准错误; 会议结束
2显示给模型的 stderr; 谈话继续 (防止停止)
other仅向用户显示 stderr; 会议结束

当回合因 API 错误(速率限制、身份验证失败)而结束时,StopFailure 会触发,而不是 Stop。 它是一劳永逸的——退出代码和输出被忽略。

用户提示提交退出代码
退出代码Effect
0stdout 注入模型上下文(向 Claude 展示)
2块处理,删除原始提示,向用户显示 stderr
otherstderr 仅显示给用户
会话启动和设置退出代码
退出代码Effect
0向 Claude 显示的标准输出(会话的种子上下文)
2两个事件都忽略阻塞错误
otherstderr 仅显示给用户

SessionStart 支持 source 具有值的匹配器: startup, resume, clear, compact。 安装程序支持 trigger 具有值的匹配器: init, maintenance.

PreCompact / PostCompact 退出代码
退出代码效果(PreCompact)
0标准输出作为自定义紧凑指令附加
2块压缩
other向用户显示 stderr; 压实继续

PostCompact:exit 0 向用户显示标准输出; 其他退出代码仅向用户显示 stderr。

CwdChanged 和 FileChanged 退出代码

两项活动均已确定 CLAUDE_ENV_FILE — 编写 bash export 语句到该文件路径,它们将应用于后续的 BashTool 命令。 这两个事件都不支持退出代码 2 阻塞; 非零退出仅向用户显示 stderr。

文件更改还支持 hookSpecificOutput.watchPaths 在 stdout JSON 中向文件观察器动态注册其他路径。 匹配器字段用作管道分隔的文件名 glob(例如 .envrc|.env).

5种钩子命令类型

The type 判别字段选择执行引擎。 所有类型,除了 function 可以坚持到 settings.json; function 仅限会话并在 TypeScript 代码中定义。

command 外壳命令

通过配置的 shell 生成一个子进程(默认:bash / your $SHELL; 也支持 powershell)。 在 stdin 上接收 JSON 格式的钩子输入。 stdout/stderr 解释遵循上面的退出代码。

关键选项: if, timeout, once, async, asyncRewake, statusMessage, shell

prompt 法学硕士提示

向 LLM 发送提示(默认:小型快速模型)。 提示使用 $ARGUMENTS 作为 JSON 挂钩输入的占位符。 模型必须响应 {"ok": true} or {"ok": false, "reason": "..."}。 强制 JSON 模式输出模式确保可解析的响应。

关键选项: if, timeout (默认30秒), model, once, statusMessage

agent 代理验证器

启动完整的多回合子代理(最多 50 回合),可以访问所有工具。 代理读取通过系统提示符注入的路径处的对话记录,然后调用 StructuredOutput 返回工具 {"ok": true/false, "reason": "..."}。 不允许的工具(AgentTool、计划模式)将被过滤掉以防止递归。

关键选项: if, timeout (默认 60 秒), model, once, statusMessage

http HTTP POST

将挂钩输入 JSON 发布到配置的 URL。 响应由调用者解释。 支持标头值中的环境变量插值(仅在 allowedEnvVars 已解决)。 受到 SSRF 防护的保护,该防护会阻止私有/本地链路 IP 范围; 故意允许环回(127.x)。

关键选项: if, timeout (默认 10 分钟), headers, allowedEnvVars, once, statusMessage

function TypeScript 回调

通过编程方式注册的进程内 TypeScript 函数 addFunctionHook()。 返回一个布尔值或 Promise<boolean>。 仅限会话范围 - 无法持久化到 settings.json。 由技能改进系统和结构化输出执行在内部使用。

关键选项: id (以便稍后删除), timeout (默认5秒), errorMessage

提示钩子不使用工具调用。 The prompt hook 查询模型 queryModelWithoutStreaming 并使用 JSON 模式输出模式强制可解析响应。 它不会触发 UserPromptSubmit hooks——这将是无限递归。 同样的模式适用于 agent hooks.

配置源和优先级

Hooks 可以有六个来源。 当同一个事件+匹配器有来自多个源的钩子时,它们会被合并并全部运行; 优先顺序(从 SOURCES in settings/constants.ts) 确定显示顺序 /hooks,但所有钩子都会执行。

Priority 来源名称 文件路径 Scope
1 userSettings ~/.claude/settings.json 该用户的所有项目
2 projectSettings .claude/settings.json (项目根) 所有参与此存储库工作的人
3 localSettings .claude/settings.local.json 仅您的机器+此项目
4 policySettings MDM/托管配置(只读) 企业管理员强制执行
5 pluginHook ~/.claude/plugins/*/hooks/hooks.json 插件安装的钩子
6 sessionHook 仅限内存中 当前会话; 退出时清除
策略设置可以控制一切。 If allowManagedHooksOnly: true 在托管 (MDM) 设置中设置, only 托管钩子运行——用户、项目、本地和插件钩子都被抑制。 如果 disableAllHooks: true 出现在托管设置中,零挂钩运行(包括托管)。 但如果 disableAllHooks 在非托管源中,托管挂钩仍然运行 - 非托管设置无法禁用托管挂钩。

钩子如何搭配

钩子事件数组中的每个条目都是一个 HookMatcher — 具有可选属性的对象 matcher 字符串和钩子命令数组。 对于支持匹配器的事件(例如 PreToolUse 匹配于 tool_name; Notification 匹配于 notification_type; SessionStart 匹配于 source),用匹配的字符串运行钩子; 没有匹配器的钩子无条件运行。

// ~/.claude/settings.json — minimal example
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",        // only fires when tool_name === "Write"
        "hooks": [
          { "type": "command", "command": "./scripts/pre-write.sh" }
        ]
      },
      {
        // no matcher → runs for every tool call
        "hooks": [
          { "type": "command", "command": "./scripts/audit-log.sh" }
        ]
      }
    ]
  }
}
settings.json

会话挂钩

会话挂钩仅存在于内存中,附加到特定的 sessionId 存储在一个 Map<string, SessionStore>。 它们是通过以下三个函数以编程方式创建的: sessionHooks.ts:

// Adding a command/prompt/agent/http hook for this session
addSessionHook(setAppState, sessionId, 'Stop', '', {
  type: 'command',
  command: './verify-output.sh'
})

// Adding a TypeScript callback hook (function type)
const hookId = addFunctionHook(
  setAppState, sessionId,
  'Stop',
  '',                                // matcher (empty = all)
  (messages, signal) => checkCondition(messages),  // callback
  'Condition not met',             // errorMessage if false
  { timeout: 5000, id: 'my-hook' }   // optional
)

// Removing a function hook by ID
removeFunctionHook(setAppState, sessionId, 'Stop', hookId)
sessionHooks.ts API

会话挂钩是 not 反应性的——他们坐在 Map (不是一个普通的对象)这样 Object.is(next, prev) 在状态更新期间返回 true 并且不会触发不必要的重新渲染。 地图发生了原地变异; 仅会话范围的数据发生变化。

来自 frontmatter 的会话挂钩

当加载技能或代理时,其 frontmatter hooks 部分通过以下方式注册为会话挂钩 registerSkillHooks() or registerFrontmatterHooks()。 这些挂钩仅在会话(或代理)处于活动状态时才存在。

细节一:代理触发 SubagentStop, 不是 Stop。 这 registerFrontmatterHooks() 函数自动转换任何 Stop 代理的 frontmatter 中的条目 SubagentStop 所以他们会在正确的地点开火。

一次:真正的钩子是自毁的。 当钩子命令包含 "once": true, registerSkillHooks() 附上一个 onHookSuccess 调用的回调 removeSessionHook() 第一次成功执行时。 这是一次性初始化模式的机制。

异步和异步唤醒

命令挂钩支持两个异步标志。 什么时候 async: true,挂钩进程启动,但 Claude 没有等待 - 对话立即继续。 该过程被跟踪在 AsyncHookRegistry 并在主循环中进行轮询。

When asyncRewake: true,挂钩在后台运行,但如果进程以代码 2 退出,则主循环会唤醒模型。这允许后台观察程序以阻塞错误中断 Claude 的下一个响应,即使挂钩本身异步运行。

{
  "PostToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "./run-tests-background.sh",
          "asyncRewake": true   // wakes model if tests fail (exit 2)
        }
      ]
    }
  ]
}
asyncRewake example

注册表通过以下方式跟踪每个挂起的挂钩 processId。 当主循环通过轮询时 checkForAsyncHookResponses(),完成的钩子被最终确定,并且它们的标准输出被解析为 JSON SyncHookJSONOutput 回复。 如果没有找到,则响应对象默认为 {}.

HTTP 挂钩:安全模型

HTTP 将 POST JSON 挂钩到 URL。 安全模型分为三层:

  1. 网址白名单: If allowedHttpHookUrls 在合并设置中设置,仅匹配列出的 glob 模式的 URL(使用 * 作为通配符)是允许的。 空数组会阻止所有 HTTP 挂钩。
  2. 环境变量白名单: 标头值可以包含 $VAR_NAME 插值,但仅限钩子中列出的变量 allowedEnvVars 数组已解决。 不在列表中的变量将替换为空字符串,以防止通过项目挂钩进行秘密泄露。
  3. SSRF守卫: DNS解析被拦截 ssrfGuardedLookup(),它会阻止私有 IP 范围(10.x、172.16-31.x、192.168.x、169.254.x、100.64-127.x)和未指定/链接本地 IPv6。 本地开发服务器特意允许环回(127.0.0.1,::1)。 当沙箱代理或 env-var 代理处于活动状态时,防护措施将被绕过(代理执行自己的 DNS;阻止代理自己的 IP 将破坏专用网络上的公司代理)。
带有 auth 标头的 HTTP 挂钩(真实代码模式)
{
  "Stop": [
    {
      "hooks": [
        {
          "type": "http",
          "url": "https://api.example.com/claude-stop-event",
          "headers": {
            "Authorization": "Bearer $MY_TOKEN"
          },
          "allowedEnvVars": ["MY_TOKEN"],
          "timeout": 10
        }
      ]
    }
  ]
}
settings.json — http hook with env var auth

The $MY_TOKEN 标头值中的引用仅被解​​析,因为 MY_TOKEN 出现在 allowedEnvVars。 任何其他 $VAR 同一标头模板中的内容将默默地替换为空字符串。 标头值也会被清理以去除 CR/LF/NUL 以防止 HTTP 标头注入。

提示和代理挂钩:深入探讨

提示钩子

提示钩子使用以下命令向 LLM 发送消息 queryModelWithoutStreaming。 系统提示告诉模型以 JSON 匹配的方式响应 {"ok": boolean, "reason"?: string}。 输出格式是通过 JSON 模式输出模式强制执行的——模型不能产生任何无法解析的内容。

该钩子解析参数占位符($ARGUMENTS, $0, $ARGUMENTS[0]等)发送前。 使用的模型默认为“小型快速模型”(可通过​​每个钩子进行配置) model field).

如果模型返回 {"ok": false},钩子结果为 blocking 与一个 preventContinuation: true 旗帜。 如果返回的话 {"ok": true},挂钩成功。 格式错误的 JSON 或架构验证失败会产生非阻塞错误。

代理挂钩

代理钩子通过以下方式运行完整的多轮代理循环(最多 50 轮) query()。 代理获得自定义系统提示,包括对话记录的路径、对按权限过滤的所有工具的访问权限,以及 StructuredOutput 它必须调用工具才能返回结果。 结构化输出强制注册为会话级 Stop 代理循环启动之前的函数钩子,并在之后清理。

如果代理达到 50 回合而没有呼叫 StructuredOutput,结果是 cancelled — 不会向用户显示任何错误。 如果代理完成时根本没有调用该工具,则同样适用。

结构化输出执行模式(真实来源)
// hookHelpers.ts — registerStructuredOutputEnforcement
addFunctionHook(
  setAppState,
  sessionId,
  'Stop',
  '',               // no matcher = all stops
  messages => hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME),
  `You MUST call the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool to complete this request. Call this tool now.`,
  { timeout: 5000 }
)
hookHelpers.ts

该函数钩子在代理停止之前触发,检查是否成功 StructuredOutput 工具调用存在于消息历史记录中,如果不存在,则注入错误消息以强制模型在完成之前调用它。

挂钩事件系统(SDK 遥测)

除了执行挂钩之外,Claude Code 还通过单独的进程内事件总线向 SDK 使用者发出挂钩执行事件 hookEvents.ts。 这与钩子执行系统不同——它纯粹是可观察性/遥测。

三种事件类型

  • started — 当钩子开始执行时发出
  • progress — 在钩子运行时以轮询间隔(默认 1s)发出,携带当前的 stdout/stderr
  • response — 当钩子完成时发出,带有完整的输出、退出代码和结果

始终发出的事件

无论发生什么,总是会发出两个事件 includeHookEvents SDK 选项: SessionStart and Setup。 这些在源代码中被描述为“原始允许列表中的低噪声生命周期事件,并且向后兼容”。 所有其他事件都需要 includeHookEvents: true 在 SDK 选项中或在中运行 CLAUDE_CODE_REMOTE mode.

最多可缓冲 100 个事件 pendingEvents 如果尚未注册处理程序(例如, SDK 使用者在前几个钩子触发后附加)。 注册处理程序后,缓冲的事件将立即刷新。

The if 过滤字段

每个持久化的钩子命令类型都支持一个可选的 if 场地。 它使用权限规则语法 - 与以下语法相同 allowedTools 模式。 示例: "Bash(git *)", "Read(*.ts)"。 仅当工具调用与模式匹配时才会生成挂钩。

{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "./git-safety.sh",
          "if": "Bash(git push*)"  // only fire on git push commands
        }
      ]
    }
  ]
}
if field — only fires for git push

The if 字段是钩子标识的一部分:具有相同命令但不同的两个钩子 if 值被视为不同的钩子,并且在会话注册它们时运行。 这 shell 字段也是身份的一部分—— "command": "foo" with "shell": "bash" and "command": "foo" with "shell": "powershell" 是不同的钩子。

现实世界的模式

模式 1:写入时 Lint 保护 (PreToolUse)
{
  "PreToolUse": [
    {
      "matcher": "Write",
      "hooks": [
        {
          "type": "command",
          "command": "jq -e '.tool_input.file_path | test(\"test.*\\.ts$\")' <<< \"$CLAUDE_HOOK_INPUT\" && echo 'Must write tests alongside implementation' >&2 && exit 2 || exit 0",
          "statusMessage": "Checking test coverage policy..."
        }
      ]
    }
  ]
}

如果工具输入路径与测试文件模式不匹配,则 exit 2 会阻止写入,并向 Claude 显示错误,然后 Claude 将重新考虑。 退出 0 允许写入静默进行。

模式2:会话上下文注入(SessionStart)
{
  "SessionStart": [
    {
      "matcher": "startup",
      "hooks": [
        {
          "type": "command",
          "command": "echo \"Today is $(date). Open PRs: $(gh pr list --json number | jq length)\""
        }
      ]
    }
  ]
}

退出 0 上的 stdout 向 Claude 显示为会话启动上下文。 该模型在第一个用户提示之前就看到了这一点。 适合注入动态环境信息(日期、PR 状态、分支、部署状态),否则需要用户手动提供上下文。

模式 3:使用代理挂钩停止验证
{
  "Stop": [
    {
      "hooks": [
        {
          "type": "agent",
          "prompt": "Verify that the implementation includes unit tests and that they all pass. Read the transcript at $ARGUMENTS[transcript_path] to understand what was built, then run the tests.",
          "timeout": 120
        }
      ]
    }
  ]
}

代理挂钩生成一个完整的子代理,可以访问所有工具。 它可以读取文件、运行命令和检查记录。 如果返回的话 {"ok": false, "reason": "Tests failed: 3 assertions"},会话继续,克劳德必须解决故障。

模式 4:.envrc 在 cwd 更改时自动加载 (CwdChanged)
{
  "CwdChanged": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "[ -f .envrc ] && direnv export bash >> \"$CLAUDE_ENV_FILE\" || true"
        }
      ]
    }
  ]
}

Claude Code 套 CLAUDE_ENV_FILE 到临时文件路径。 写作 export VAR=value 那里的行会导致这些环境变量应用于后续的 BashTool 命令。 此模式模仿 direnv 集成,而不运行完整的 direnv 守​​护进程。

模式5:基于LLM的策略检查(提示钩子)
{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "prompt",
          "prompt": "The following bash command is about to run: $ARGUMENTS\nReturn ok: true only if the command does not destructively modify production infrastructure. Common destructive patterns: terraform destroy, kubectl delete on prod namespaces, DROP TABLE, rm -rf on critical paths.",
          "model": "claude-sonnet-4-6"
        }
      ]
    }
  ]
}

提示钩子非常适合难以用正则表达式表达的模糊策略决策。 该模型评估意图,而不仅仅是模式匹配。 使用更强大的模型(十四行诗而不是俳句)可以提高细微差别案例的准确性。

要点

1
正好有 27 个挂钩事件。 它们定义在 HOOK_EVENTS in src/entrypoints/sdk/coreTypes.ts。 如果添加了新事件,它会首先出现在那里。 元数据(描述、匹配器字段、有效匹配器值)位于 hooksConfigManager.ts → getHookEventMetadata().
2
退出代码 2 是通用的“阻止并告诉模型”代码。 所有其他非零代码仅是用户可见的噪声。 这种区别是理解钩子协议最重要的一点。 StopFailure 是唯一的例外 - 它忽略所有退出代码。
3
The 5种钩子命令类型 形成权力与简单的层次结构: command (外壳子进程)→ prompt (单一法学硕士课程)→ agent (全程多回合代理)→ http (发布到 URL)→ function (TypeScript 回调,仅限会话)。 更简单的类型具有更低的延迟并且更可预测。
4
六个配置源,一个合并。 用户>项目>本地>策略>插件>会话。 策略设置 (MDM) 具有特殊权力: allowManagedHooksOnly 压制其他一切; disableAllHooks 在策略中甚至会杀死托管钩子。 非托管设置无法阻止托管挂钩。
5
会话挂钩有意使用映射,而不是记录。 地图已就地变异,因此 Object.is(next, prev) 返回 true 并且存储侦听器不会触发。 这在并行代理工作流程中很重要,其中在一次tick中注册了数十个函数挂钩。
6
HTTP hooks 有一个三层安全模型: URL 允许列表(全局模式)、环境变量允许列表(仅列出标头中插入的变量)和 SSRF 防护(阻止私有 IP 范围,允许环回)。 默认情况下,项目级挂钩无法泄露秘密或到达内部基础设施。
7
The 挂钩事件总线 (hookEvents.ts) 与钩子执行是分开的。 这纯粹是可观察性。 仅有的 SessionStart and Setup 事件总是被发出; 所有其他人都需要 includeHookEvents: true 或远程模式。 如果尚未附加 SDK 使用者,则最多可缓冲 100 个事件。

测验

Q1 PreToolUse 挂钩脚本以代码 1 退出并写入 stderr。 会发生什么?
Q2 在 Claude 第一次响应之前,您将使用哪个钩子事件来注入动态上下文(例如,当前的 git 分支)?
Q3 代理挂钩达到 50 圈而不调用 StructuredOutput。 结果如何?
Q4 你希望只有当 Claude 运行时钩子才会触发 git push bash 命令,而不是其他 bash 命令。 哪个字段控制这个?
Q5 贵公司的 MDM 政策集 disableAllHooks: true in policySettings。 插件还注册钩子。 什么运行?
Q6 你添加 "asyncRewake": true 到 PostToolUse 命令挂钩。 模型什么时候会被中断?