18-Hooks系统
第 18 篇:Hooks 系统 — 用 Shell 命令扩展 AI 行为
本篇是《深入 Claude Code 源码》系列第 18 篇。我们将深入 Hooks 系统的完整实现,揭示 Claude Code 如何让用户在 AI 生命周期的关键节点注入自定义逻辑,以及这套系统在安全性、可扩展性和性能上的精巧设计。
为什么需要 Hooks?
想象一个场景:你希望 Claude Code 在每次执行 Write 工具后自动运行 prettier 格式化代码,或者在 Session 启动时自动加载项目特定的环境变量,又或者在模型即将结束回答时用一个验证脚本检查代码质量。
这些需求有一个共同特征:它们不是 AI 本身的能力,而是用户希望在 AI 工作流的特定节点注入的自定义行为。
Hooks 系统正是为此而生。它是 Claude Code 的”生命周期钩子”框架 —— 类似于 Git Hooks、Webpack Plugins 或 React 的 useEffect,但面向的是 AI Agent 的执行周期。用户通过 settings.json 声明:在哪个事件(event)、匹配什么条件(matcher)时,执行什么命令(hook)。
一、事件类型全景:27 个生命周期节点
Hooks 系统定义了 27 个事件类型,覆盖了 AI 交互的完整生命周期。这些事件在 entrypoints/sdk/coreSchemas.ts:355-383 中以 as const 数组定义:
1 | // entrypoints/sdk/coreSchemas.ts:355-383 |
这些事件可以按功能域分为六大类:
| 类别 | 事件 | 用途 |
|---|---|---|
| 工具生命周期 | PreToolUse, PostToolUse, PostToolUseFailure |
拦截/审计/增强工具调用 |
| 会话生命周期 | SessionStart, SessionEnd, Setup, Stop, StopFailure, UserPromptSubmit |
初始化/清理/验证 |
| 权限与安全 | PermissionRequest, PermissionDenied |
自定义权限决策 |
| Agent 协作 | SubagentStart, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted |
多 Agent 编排 |
| 上下文管理 | PreCompact, PostCompact, Notification |
压缩/通知控制 |
| 环境感知 | ConfigChange, CwdChanged, FileChanged, InstructionsLoaded, Elicitation, ElicitationResult, WorktreeCreate, WorktreeRemove |
响应外部变化 |
二、配置模型:Event → Matcher → Hook 的三层结构
Hooks 的配置通过 settings.json 声明,采用三层嵌套结构。其 Zod Schema 定义在 schemas/hooks.ts:194-213:
1 | // schemas/hooks.ts:194-213 |
一个实际配置示例:
1 | { |
2.1 四种 Hook 类型
HookCommand 是一个 discriminated union,通过 type 字段区分四种持久化类型(schemas/hooks.ts:31-189):
1 | // schemas/hooks.ts:176-189 |
| 类型 | 执行方式 | 典型场景 |
|---|---|---|
command |
spawn() 启动 Shell 进程 |
运行 lint/test/格式化脚本 |
prompt |
调用 LLM(默认 getSmallFastModel(),通常为 Haiku)评估条件 |
“检查代码是否有安全漏洞” |
agent |
启动多轮 Agent 对话验证 | “验证单元测试是否通过” |
http |
HTTP POST 到指定 URL | 调用外部审批/CI 系统 |
除了这四种可持久化类型,运行时还有两种内存级类型:
callback:TypeScript 回调函数,用于 SDK 注册和内部监控(如文件访问追踪、commit attribution)function:Session 级 TypeScript 回调,用于结构化输出强制校验
2.2 Matcher 匹配逻辑
matcher 字段控制 Hook 何时触发。matchesPattern() 函数(utils/hooks.ts:1346-1381)支持三种模式:
1 | // utils/hooks.ts:1346-1381 |
三种匹配模式:
- 精确匹配:
"Write"— 精确匹配工具名 - 多值匹配:
"Write|Edit"— 管道分隔的多个精确值 - 正则匹配:
"^Bash.*"— 完整的正则表达式
此外,if 条件字段提供了更细粒度的过滤(schemas/hooks.ts:19-27),使用权限规则语法(如 "Bash(git *)" 只匹配 git 命令),避免为不匹配的工具调用启动进程。
三、执行引擎:从事件触发到结果收集
3.1 核心执行流程
整个 Hook 执行由 executeHooks() 这个 AsyncGenerator 函数驱动(utils/hooks.ts:1952-2972)。它的完整流程如下:
1 | flowchart TD |
3.2 安全防线:信任检查
所有 Hook 执行的第一道关卡是 workspace trust 检查(utils/hooks.ts:267-296):
1 | // utils/hooks.ts:286-296 |
这段代码背后有深刻的安全考量。注释(utils/hooks.ts:267-285)记录了两个曾经存在的历史漏洞:
- SessionEnd hooks 在用户拒绝信任对话框时仍会执行
- SubagentStop hooks 在 subagent 在信任确认之前完成时会执行
因此,当前设计采用了集中式防御:无论是哪种事件类型,只要在交互模式下未通过信任对话框,一律跳过。这是典型的 defense-in-depth 策略。
3.3 Hook 配置来源的多路合并
getHooksConfig() 函数(utils/hooks.ts:1492-1566)从三个来源合并 Hook 配置:
1 | // utils/hooks.ts:1492-1566(简化) |
来源 1 — Settings 快照:在 setup() 阶段由 captureHooksConfigSnapshot() 捕获(utils/hooks/hooksConfigSnapshot.ts:95-97),默认按快照执行。但在特定流程中会通过 updateHooksConfigSnapshot() 刷新:进入 worktree 后重新读取(setup.ts:284)、退出 worktree 时恢复(ExitWorktreeTool.ts:140)、以及设置文件变更时同步(applySettingsChange.ts:42)。这种”快照 + 定点刷新”的设计在防止 mid-session 随意注入的同时,保证了配置变更能在合理的时机生效。
来源 2 — 注册 Hook:SDK 回调和 Plugin 原生 Hook 通过 bootstrap/state.ts 的 getRegisteredHooks() 获取。
来源 3 — Session Hook:Agent 和 Skill 通过 frontmatter 定义的 Hook,在运行时注册到 AppState.sessionHooks 中(一个 Map<string, SessionStore>)。关键的设计决策是:Session Hook 使用 Map 而非 Record(utils/hooks/sessionHooks.ts:48-62),原因是并发性能 —— 在 parallel() 模式下,N 个 Agent 可能在同一个 tick 中注册 Hook。Map.set() 是 O(1),而 Record + spread 是 O(N)。
3.4 去重与 if 条件过滤
getMatchingHooks() 在匹配后会进行去重(utils/hooks.ts:1720-1806)。去重的关键设计是按来源命名空间隔离:
1 | // utils/hooks.ts:1453-1455 |
同一个 Plugin 的重复 Hook 会被合并,但来自不同 Plugin 的相同命令模板不会 —— 因为 ${CLAUDE_PLUGIN_ROOT}/hook.sh 展开后指向不同的文件。
对于 callback 和 function 类型的 Hook,直接跳过去重逻辑(utils/hooks.ts:1723-1729)—— 这是一个性能优化,对内部 Hook(如 sessionFileAccessHooks)来说,跳过 6 轮 filter + 4 个 Map + 4 个 Array.from 带来了 44 倍的微基准性能提升。
四、Shell 命令执行:execCommandHook 的完整链路
execCommandHook()(utils/hooks.ts:747-1335)是整个 Hooks 系统中最复杂的函数,约 590 行。它处理的不只是”跑一个 Shell 命令”这么简单 —— 它需要应对跨平台 Shell 差异、异步后台化、Prompt Elicitation 协议、超时控制等多种场景。
4.1 Shell 选择与跨平台适配
Hook 支持两种 Shell(utils/hooks.ts:790-984):
1 | // utils/hooks.ts:790-791 |
两种 Shell 的 spawn 方式完全不同:
1 | // utils/hooks.ts:958-984 |
在 Windows 上,Bash Hook 通过 Git Bash 执行(而非 cmd.exe),所有路径都需要转换为 POSIX 格式(C:\Users\foo → /c/Users/foo)。PowerShell Hook 则保持原生 Windows 路径。
实现细节:虽然配置中叫
shell: 'bash',但在非 Windows 平台上,Node.js 的spawn(..., { shell: true })实际使用的是/bin/sh(源码注释utils/hooks.ts:975明确写到 “On other platforms, shell: true uses /bin/sh”)。这意味着 Hook 命令应该使用 POSIX shell 兼容语法,而非依赖 GNU bash 特有的特性(如[[条件表达式、数组等)。如果确实需要 bash,应在命令中显式调用bash -c '...'。
4.2 环境变量注入
每个 Hook 进程都会收到一组精心准备的环境变量(utils/hooks.ts:882-926):
1 | // utils/hooks.ts:882-926(简化) |
CLAUDE_ENV_FILE 是一个特殊机制:Hook 可以向这个文件写入 export VAR=value 格式的环境变量定义,后续的 BashTool 命令会自动加载这些变量。这让 SessionStart Hook 可以为整个会话设置环境。
4.3 输入输出协议
Hook 通过 stdin 接收 JSON 输入,通过 stdout 返回 JSON 或纯文本输出:
1 | ┌──────────┐ stdin (JSON) ┌──────────┐ stdout (JSON/text) ┌──────────┐ |
输入是 HookInput 的 JSON 序列化,包含事件名、工具名、工具输入、session ID 等上下文信息。
输出的解析由 parseHookOutput()(utils/hooks.ts:399-451)处理:
1 | // utils/hooks.ts:399-451(简化) |
4.4 Exit Code 语义
Hook 的 exit code 有明确的语义约定(utils/hooks.ts:2617-2697):
| Exit Code | 含义 | 处理方式 |
|---|---|---|
| 0 | 成功 | stdout 作为成功消息 |
| 2 | 阻塞性错误 | stderr 反馈给模型,阻止操作继续 |
| 其他 | 非阻塞性错误 | stderr 展示给用户,但操作继续 |
Exit code 2 的设计非常巧妙 —— 它让 Hook 可以阻止 AI 的操作并给出反馈。比如一个 PreToolUse Hook 返回 exit code 2,AI 会收到错误消息并调整行为:
1 | // utils/hooks.ts:2648-2668 |
4.5 JSON 输出协议
当 Hook 输出以 { 开头时,系统尝试将其解析为结构化 JSON 响应。syncHookResponseSchema(types/hooks.ts:50-166)定义了丰富的输出字段:
1 | // types/hooks.ts:50-66(简化) |
特别值得注意的是 updatedInput 字段 —— PreToolUse Hook 可以通过它修改工具的输入参数。这意味着 Hook 不仅可以拦截,还可以改写 AI 的工具调用。
五、异步 Hook:后台执行与唤醒机制
Hooks 支持两种异步模式,由配置字段 async 和 asyncRewake 控制。
5.1 配置级异步
当 hook.async = true 时,Hook 在启动后立即后台化(utils/hooks.ts:995-1030):
1 | // utils/hooks.ts:995-1030(简化) |
5.2 协议级异步
Hook 也可以在运行时动态切换为异步。当 Hook 的 stdout 第一行输出 {"async": true} 时,系统检测到后自动后台化(utils/hooks.ts:1112-1164):
1 | // utils/hooks.ts:1117-1143(简化) |
5.3 asyncRewake:后台执行 + 唤醒模型
asyncRewake 是一个更高级的模式(utils/hooks.ts:205-246)。Hook 在后台运行,但如果以 exit code 2 退出,会通过 enqueuePendingNotification() 唤醒模型:
1 | // utils/hooks.ts:218-243(简化) |
5.4 Async Hook Registry
后台化的 Hook 被注册到全局的 AsyncHookRegistry(utils/hooks/AsyncHookRegistry.ts)。pendingHooks 是一个 Map<string, PendingAsyncHook>:
1 | // utils/hooks/AsyncHookRegistry.ts:28 |
checkForAsyncHookResponses()(AsyncHookRegistry.ts:113-268)在每个 query turn 中被调用,检查已完成的异步 Hook 并收集其输出。这个函数使用 Promise.allSettled() 而非 Promise.all(),确保一个 Hook 的失败不会影响其他 Hook 的结果收集。
六、Prompt 与 Agent Hook:用 AI 验证 AI
6.1 Prompt Hook
Prompt Hook 让用户用自然语言定义验证条件,由一个小模型评估。默认使用 getSmallFastModel()(utils/model/model.ts:36-38,当前映射到 Haiku,但可通过 ANTHROPIC_SMALL_FAST_MODEL 环境变量覆盖)。execPromptHook()(utils/hooks/execPromptHook.ts:21-211)的核心逻辑:
1 | // utils/hooks/execPromptHook.ts:62-100(简化) |
关键设计点:
- 使用
json_schema结构化输出确保模型返回可解析的结果 - 禁用 thinking(
type: 'disabled')减少延迟 - 默认使用
getSmallFastModel()(当前为 Haiku,可通过环境变量覆盖),也允许通过hook.model指定任意模型 $ARGUMENTS占位符被替换为 Hook 输入 JSON
6.2 Agent Hook
Agent Hook(utils/hooks/execAgentHook.ts)更进一步 —— 它启动一个完整的多轮对话,让 Agent 可以调用工具来验证条件。这对于”运行测试并检查结果”这类需要实际执行的验证特别有用。
七、权限决策协议
PreToolUse Hook 可以做出权限决策,影响工具是否执行。决策优先级遵循严格的层级(utils/hooks.ts:2820-2847):
1 | // utils/hooks.ts:2826-2847 |
优先级链:deny > ask > allow > passthrough。这意味着:
- 如果任何一个 Hook 说
deny,操作一定被阻止 - 如果没有
deny但有ask,会弹出确认对话框 - 只有在所有 Hook 都同意(或 passthrough)时,才会自动
allow
这是一个安全优先的设计 —— 最严格的决策获胜。
八、安全边界:三级管控策略
Hooks 系统的安全管控由 hooksConfigSnapshot.ts 实现,分为三个层级:
8.1 三级开关
1 | // utils/hooks/hooksConfigSnapshot.ts:62-88 |
层级关系:
- 正常模式:所有来源的 Hook 都执行
- managedOnly 模式:只执行
policySettings(企业管理策略)中的 Hook,用户/项目级 Hook 被跳过 - disableAll 模式:所有 Hook 都不执行(包括管理 Hook)
8.2 快照隔离
Hook 配置在 setup() 阶段被捕获为快照(captureHooksConfigSnapshot()),默认按快照执行。快照会在特定时机通过 updateHooksConfigSnapshot() 刷新(worktree 切换、设置文件变更),但不会因任意的文件修改而实时更新。这种”快照 + 定点刷新”的设计防止了 mid-session 的随意配置注入:
1 | // utils/hooks/hooksConfigSnapshot.ts:95-97 |
8.3 HTTP Hook 的 SSRF 防护
HTTP Hook 内置了 SSRF(Server-Side Request Forgery)防护(utils/hooks/ssrfGuard.ts)。isBlockedAddress() 函数阻止对私有/链路本地地址的请求(169.254.0.0/16、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 等),但刻意允许环回地址(127.0.0.0/8)—— 因为本地开发策略服务器是 HTTP Hook 的主要使用场景。
九、Frontmatter Hook 注册
Agent 和 Skill 可以在 markdown frontmatter 中定义 Hook,这些 Hook 被注册为 Session 级别的临时 Hook。registerFrontmatterHooks()(utils/hooks/registerFrontmatterHooks.ts:18-67)处理注册逻辑:
1 | // utils/hooks/registerFrontmatterHooks.ts:18-67(简化) |
一个巧妙的设计:Agent 中定义的 Stop Hook 自动被转换为 SubagentStop,因为子 Agent 结束时触发的是 SubagentStop 而非 Stop 事件。
十、性能优化:Fast Path 与惰性序列化
Hooks 在 AI 交互的关键路径上执行,性能至关重要。源码中有几个显著的优化:
10.1 内部 Hook 的 Fast Path
当所有匹配的 Hook 都是内部 callback 时(如文件访问追踪),跳过整个 span/progress/telemetry 流程(utils/hooks.ts:2036-2067):
1 | // utils/hooks.ts:2036-2067 |
10.2 惰性 JSON 序列化
hookInput 的 JSON 序列化只在需要时执行一次,且多个 Hook 共享(utils/hooks.ts:2124-2140):
1 | // utils/hooks.ts:2124-2140 |
10.3 事件存在性快速检查
hasHookForEvent()(utils/hooks.ts:1582-1593)提供了一个轻量级的存在性检查,用于跳过无 Hook 配置时的 createBaseHookInput() 和 getMatchingHooks() 开销:
1 | // utils/hooks.ts:1582-1593 |
十一、Prompt Elicitation:Hook 与用户的双向对话
一个极其精巧的特性是 Prompt Elicitation 协议(utils/hooks.ts:1060-1110)。Hook 进程可以向 stdout 输出一个特殊的 JSON 格式来向用户提问,然后通过 stdin 接收回答:
1 | // types/hooks.ts:28-40 |
当 Hook 进程输出 {"prompt":"id1","message":"选择分支","options":[...]} 时,Claude Code 会在终端展示选项给用户,收到用户选择后将 {"prompt_response":"id1","selected":"option-key"} 写回 Hook 的 stdin。
这通过 child.stdout.on('data', ...) 中的逐行解析实现(utils/hooks.ts:1068-1110),使用 promptChain 序列化多个异步 Prompt 请求的处理顺序。
可迁移的设计模式
模式 1:Exit Code 语义协议
用 exit code 的不同值表达不同的语义(0=成功, 2=阻塞, 其他=非阻塞错误),让外部脚本可以精细控制系统行为。这比简单的”成功/失败”二元状态提供了更丰富的表达能力。
适用场景:任何需要与外部脚本/进程交互的系统,特别是 CI/CD 流水线、构建系统。
模式 2:安全优先的权限聚合
当多个来源可以对同一操作做出权限决策时,采用”最严格决策获胜”的聚合策略(deny > ask > allow)。这确保了即使有一个检查器发现问题,操作也会被拦截。
适用场景:任何多层权限检查系统、多因素审批流程。
模式 3:快照隔离的配置加载
在应用启动时捕获配置快照,运行期间使用快照而非实时读取。这既避免了 mid-session 配置变更带来的不一致性,也防止了配置注入攻击。
适用场景:安全敏感的配置系统,特别是配置可能来自不可信来源的场景(如项目级 .claude/settings.json)。
下一篇预告
第 19 篇:Feature Flag 与编译期优化 — 同一份代码构建两个产品
我们将深入 feature() 编译期机制和 GrowthBook A/B 测试集成,揭示 Claude Code 如何用同一份代码构建出内部版和外部版两个不同的产品。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)