来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列第 20 章。我们将深入 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 个生命周期事件 → 第二节 Event → Matcher → Hook 三层配置 → 第三节 执行引擎 → 第四节 Shell 命令链路 → 第五节 异步 Hook → 第六节 Prompt/Agent Hook(用 AI 验证 AI)→ 第七节 权限决策协议 → 第八节 三级安全管控 → 第九节 Frontmatter 注册 → 第十节 Fast Path 性能优化 → 第十一节 Prompt Elicitation 双向对话 → 可迁移模式。第一至第三节 是骨架,第四至第六节 是三种 hook command 类型逐一展开,第七至第十一节 是横切关注点。
一、事件类型全景:27 个生命周期节点
Hooks 系统定义了 27 个事件类型,覆盖了 AI 交互的完整生命周期。这些事件在 entrypoints/sdk/coreSchemas.ts:355-383 中以 as const 数组定义:
// entrypoints/sdk/coreSchemas.ts:355-383
export const HOOK_EVENTS = [
'PreToolUse', // 工具执行前
'PostToolUse', // 工具执行后
'PostToolUseFailure', // 工具执行失败后
'Notification', // 通知发送时
'UserPromptSubmit', // 用户提交 prompt 时
'SessionStart', // 会话开始
'SessionEnd', // 会话结束
'Stop', // 模型即将结束回答
'StopFailure', // 因 API 错误结束 turn
'SubagentStart', // 子 Agent 启动
'SubagentStop', // 子 Agent 结束
'PreCompact', // 对话压缩前
'PostCompact', // 对话压缩后
'PermissionRequest', // 权限对话框显示时
'PermissionDenied', // auto mode 拒绝工具调用后
'Setup', // 仓库 setup(init/maintenance)
'TeammateIdle', // Teammate 即将空闲
'TaskCreated', // 任务创建时
'TaskCompleted', // 任务完成时
'Elicitation', // MCP 请求用户输入时
'ElicitationResult', // 用户响应 MCP elicitation 后
'ConfigChange', // 配置文件变更时
'WorktreeCreate', // 创建 worktree 时
'WorktreeRemove', // 删除 worktree 时
'InstructionsLoaded', // 指令文件加载时
'CwdChanged', // 工作目录变更后
'FileChanged', // 被监视文件变更时
] as const
这些事件可以按功能域分为六大类:
| 类别 | 事件 | 用途 |
|---|---|---|
| 工具生命周期 | 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:
// schemas/hooks.ts:194-213
export const HookMatcherSchema = lazySchema(() =>
z.object({
matcher: z.string().optional()
.describe('String pattern to match (e.g. tool names like "Write")'),
hooks: z.array(HookCommandSchema())
.describe('List of hooks to execute when the matcher matches'),
}),
)
export const HooksSchema = lazySchema(() =>
z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())),
)
一个实际配置示例:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "echo 'About to write file' | tee -a /tmp/audit.log"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm test 2>&1 | tail -20"
}
]
}
]
}
}
2.1 四种 Hook 类型
HookCommand 是一个 discriminated union,通过 type 字段区分四种持久化类型(schemas/hooks.ts:31-189):
// schemas/hooks.ts:176-189
export const HookCommandSchema = lazySchema(() => {
return z.discriminatedUnion('type', [
BashCommandHookSchema, // type: 'command' — Shell 命令
PromptHookSchema, // type: 'prompt' — LLM 评估
AgentHookSchema, // type: 'agent' — 多轮 Agent 验证
HttpHookSchema, // type: 'http' — HTTP POST
])
})
| 类型 | 执行方式 | 典型场景 |
|---|---|---|
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)支持三种模式:
// utils/hooks.ts:1346-1381
function matchesPattern(matchQuery: string, matcher: string): boolean {
if (!matcher || matcher === '*') {
return true // 空或 * 匹配一切
}
// 纯字母数字 + 管道符 → 精确匹配或多值匹配
if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
if (matcher.includes('|')) {
const patterns = matcher.split('|').map(p => normalizeLegacyToolName(p.trim()))
return patterns.includes(matchQuery)
}
return matchQuery === normalizeLegacyToolName(matcher)
}
// 否则作为正则表达式
try {
const regex = new RegExp(matcher)
if (regex.test(matchQuery)) return true
// 兼容旧工具名
for (const legacyName of getLegacyToolNames(matchQuery)) {
if (regex.test(legacyName)) return true
}
return false
} catch { return false }
}
三种匹配模式:
- 精确匹配:
"Write"— 精确匹配工具名 - 多值匹配:
"Write|Edit"— 管道分隔的多个精确值 - 正则匹配:
"^Bash.*"— 完整的正则表达式
此外,if 条件字段提供了更细粒度的过滤(schemas/hooks.ts:19-27),使用权限规则语法(如 "Bash(git *)" 只匹配 git 命令),避免为不匹配的工具调用启动进程。
三、执行引擎:从事件触发到结果收集
3.1 核心执行流程
整个 Hook 执行由 executeHooks() 这个 AsyncGenerator 函数驱动(utils/hooks.ts:1952-2972)。它的完整流程如下:
3.2 安全防线:信任检查
所有 Hook 执行的第一道关卡是 workspace trust 检查(utils/hooks.ts:267-296):
// utils/hooks.ts:286-296
export function shouldSkipHookDueToTrust(): boolean {
// 非交互模式(SDK)中,信任是隐式的
const isInteractive = !getIsNonInteractiveSession()
if (!isInteractive) {
return false
}
// 交互模式下,所有 Hook 都需要 workspace trust
const hasTrust = checkHasTrustDialogAccepted()
return !hasTrust
}
这段代码背后有深刻的安全考量。注释(utils/hooks.ts:267-285)记录了两个曾经存在的历史漏洞:
- SessionEnd hooks 在用户拒绝信任对话框时仍会执行
- SubagentStop hooks 在 subagent 在信任确认之前完成时会执行
因此,当前设计采用了集中式防御:无论是哪种事件类型,只要在交互模式下未通过信任对话框,一律跳过。这是典型的 defense-in-depth 策略。
3.3 Hook 配置来源的多路合并
getHooksConfig() 函数(utils/hooks.ts:1492-1566)从三个来源合并 Hook 配置:
// utils/hooks.ts:1492-1566(简化)
function getHooksConfig(appState, sessionId, hookEvent) {
// 来源 1:Settings 快照(startup 时捕获)
const hooks = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])]
// 来源 2:注册的 Hook(SDK callback + Plugin native hooks)
const registeredHooks = getRegisteredHooks()?.[hookEvent]
if (registeredHooks) {
for (const matcher of registeredHooks) {
if (managedOnly && 'pluginRoot' in matcher) continue // 受管模式跳过插件
hooks.push(matcher)
}
}
// 来源 3:Session 级 Hook(Agent/Skill frontmatter 注册的临时 Hook)
if (!managedOnly && appState !== undefined) {
const sessionHooks = getSessionHooks(appState, sessionId, hookEvent)
// ... 合并 session hooks 和 function hooks
}
return hooks
}
来源 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)。去重的关键设计是按来源命名空间隔离:
// utils/hooks.ts:1453-1455
function hookDedupKey(m: MatchedHook, payload: string): string {
return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}
同一个 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 倍的微基准性能提升。
3.5 一回合结束的最后一道关:Stop 与 SubagentStop
设想这样一幕:模型刚刚说"答完了",正准备把麦克风交还给用户。这时有一道关卡会再问一句:"你确定吗?要不要再想想?" 这就是 Stop 与 SubagentStop hook 的位置 —— 一个 turn 真正结束之前的最后一道决策门。
它的运行节拍画出来是这样:
干这件事的函数叫 handleStopHooks(),节奏分三段,每段干一件事。
第一段是后台清洗。如果不是 --bare 这种极简模式,就顺手启动三条 fire-and-forget 链路 —— 主流程不等它们跑完:
// query/stopHooks.ts:136-156(节选)
if (!isBareMode()) {
if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) {
void executePromptSuggestion(stopHookContext)
}
if (feature('EXTRACT_MEMORIES') && !toolUseContext.agentId && isExtractModeActive()) {
void extractMemoriesModule!.executeExtractMemories(stopHookContext, ...)
}
if (!toolUseContext.agentId) {
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
}
}
三个 void 三种意图:给下一轮准备 prompt 建议、抽取本轮值得记住的事实、触发"自动复盘"。注意 extractMemories 和 autoDream 都拒绝子 Agent 触发(!toolUseContext.agentId),因为这是 session 级的脑回路,跟子任务无关。
第二段才是真正运行用户配置的 Stop hook,事件名按身份分叉 —— 主 session 触发 Stop,子 Agent 触发 SubagentStop,同一段执行引擎,两个挂钩点:
// query/stopHooks.ts:180-189(节选)
const generator = executeStopHooks(
permissionMode,
toolUseContext.abortController.signal,
undefined,
stopHookActive ?? false,
toolUseContext.agentId, // ← 有 agentId 就是 SubagentStop
toolUseContext,
[...messagesForQuery, ...assistantMessages],
toolUseContext.agentType,
)
第三段是收结果。hook 逐条返回后被分成三摞:报错的、有输出的、阻塞的。如果某条 hook 明确说"不行,别结束",它的反馈会被打包成一条 user message 塞回队列 —— 模型在下一个 turn 一睁眼就会看到,于是接着想下去。
所以 Stop hook 不是"会话结束钩子",而是"会话愿不愿意结束的决策钩子"。模型说"我答完了",hook 说"再想想",于是 turn 继续。这跟 PreToolUse、PostToolUse 那种"工具调用前后切一刀"的钩子是完全不同的位面 —— 它绑定的是对话的节拍,不是单次动作。
异步分支也共用同一套语义。比如 hook 在后台跑测试,跑完失败了,它不需要把当前 turn 拽回来,而是把失败打包成系统提醒,等模型下一次 idle 时再喂进去 —— 这就是前面 第 5.3 节 讲过的 asyncRewake 模式。同步把当前 turn 拽回去,异步把未来某个 turn 拽回去,两种姿势复用的是同一套"阻塞 = 继续聊"的协议。
附:本节涉及源码 —— query/stopHooks.ts:65-473 handleStopHooks()、query/stopHooks.ts:133-157 三条后台链路、query/stopHooks.ts:175-189 主流程、utils/hooks.ts:3653-3656 事件名分叉与 hasHookForEvent() 快检。
3.6 notifs:另一种叫"hook"的东西
读源码的时候你会撞到一个目录叫 hooks/notifs/,看名字像是 Hooks 系统的一部分。不是。这里的 "hook" 是 React 那个 hook,跟前面讲的执行引擎完全是两套东西,只是凑巧重名了。
它解决的问题特别朴素:CLI 启动后要在合适的时机弹一些一次性的提示 —— 启动横幅、订阅状态变了、模型要迁移了、NPM 包过期了、Rate Limit 警告、LSP 初始化完成、Plugin 自动更新了……一共 16 条这样的通知,每一条都长在一个独立的 useXxxNotification 文件里。
这些通知有一个共同的脾气:只在进程启动后弹一次,远程模式下要安静。早期每条通知自己实现这套脾气,结果就是同一段守卫代码被抄了十几遍。后来抽成了一个公共自定义 hook,长这样:
// hooks/notifs/useStartupNotification.ts:19-30(节选)
export function useStartupNotification(
compute: () => Result | Promise<Result>,
): void {
const { addNotification } = useNotifications()
const hasRunRef = useRef(false)
// ...
useEffect(() => {
if (getIsRemoteMode() || hasRunRef.current) return // ← 两道守卫
hasRunRef.current = true
void Promise.resolve()
.then(() => computeRef.current())
.then(result => { /* 派发通知 */ })
.catch(logError)
}, [addNotification])
}
11 行代码兜住两件事:hasRunRef 保证"只跑一次",getIsRemoteMode() 保证"远程模式跳过"。compute 可以返回 null、一条通知、或一组通知,同步异步都行。远程跳过是因为这些提示对远端会话要么没意义(NPM 过期是 host 的事),要么 host 端自己会发(Rate Limit),两头都报反而吵。
之所以把它跟生命周期 hook 放在同一章讲,是因为两套东西名字一样、语义其实也一样:在某个时间点挂一段自定义行为。区别只在挂哪儿:
| 类别 | 挂在哪 | 触发时机 | 例子 |
|---|---|---|---|
| 生命周期 hook | query loop 的事件总线 | Shell、LLM、HTTP、Stop…… | PreToolUse 拦下危险 bash |
| notifs hook | React 渲染循环 | 组件挂载 / 状态变更 | 启动时弹一次 NPM 过期警告 |
两套体系在代码里没有任何耦合,知道这件事,下次看到目录名就不会迷路。
四、Shell 命令执行:execCommandHook 的完整链路
execCommandHook()(utils/hooks.ts:747-1335)是整个 Hooks 系统中最复杂的函数,约 590 行。它处理的不只是"跑一个 Shell 命令"这么简单 —— 它需要应对跨平台 Shell 差异、异步后台化、Prompt Elicitation 协议、超时控制等多种场景。
4.1 Shell 选择与跨平台适配
Hook 支持两种 Shell(utils/hooks.ts:790-984):
// utils/hooks.ts:790-791
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL // 默认 'bash'
const isPowerShell = shellType === 'powershell'
两种 Shell 的 spawn 方式完全不同:
// utils/hooks.ts:958-984
if (shellType === 'powershell') {
// PowerShell:显式 argv,不使用 shell option
child = spawn(pwshPath, buildPowerShellArgs(finalCommand), {
env: envVars, cwd: safeCwd, windowsHide: true,
})
} else {
// Bash:shell option 让 Node 用 shell 解析整个命令字符串
// 注意:Windows 上显式使用 Git Bash,非 Windows 上 shell: true 实际使用 /bin/sh
const shell = isWindows ? findGitBashPath() : true
child = spawn(finalCommand, [], {
env: envVars, cwd: safeCwd, shell, windowsHide: true,
})
}
在 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):
// utils/hooks.ts:882-926(简化)
const envVars: NodeJS.ProcessEnv = {
...subprocessEnv(), // 继承进程环境
CLAUDE_PROJECT_DIR: toHookPath(projectDir), // 项目根目录
}
// Plugin/Skill Hook 额外变量
if (pluginRoot) {
envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot)
envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId))
}
// Plugin 配置项作为 env var 暴露
if (pluginOpts) {
for (const [key, value] of Object.entries(pluginOpts)) {
const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value)
}
}
// SessionStart/Setup/CwdChanged/FileChanged Hook 获得 CLAUDE_ENV_FILE
if (!isPowerShell && (hookEvent === 'SessionStart' || ...)) {
envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex)
}
CLAUDE_ENV_FILE 是一个特殊机制:Hook 可以向这个文件写入 export VAR=value 格式的环境变量定义,后续的 BashTool 命令会自动加载这些变量。这让 SessionStart Hook 可以为整个会话设置环境。
4.3 输入输出协议
Hook 通过 stdin 接收 JSON 输入,通过 stdout 返回 JSON 或纯文本输出:
┌──────────┐ stdin (JSON) ┌──────────┐ stdout (JSON/text) ┌──────────┐
│ Claude │ ──────────────────> │ Hook │ ───────────────────────> │ Claude │
│ Code │ │ Process │ │ Code │
└──────────┘ └──────────┘ └──────────┘
输入是 HookInput 的 JSON 序列化,包含事件名、工具名、工具输入、session ID 等上下文信息。
输出的解析由 parseHookOutput()(utils/hooks.ts:399-451)处理:
// utils/hooks.ts:399-451(简化)
function parseHookOutput(stdout: string) {
const trimmed = stdout.trim()
if (!trimmed.startsWith('{')) {
return { plainText: stdout } // 不以 { 开头 → 纯文本
}
// 尝试解析为 JSON 并通过 Zod 验证
const result = validateHookJson(trimmed)
if ('json' in result) return result
return { plainText: stdout, validationError: result.validationError }
}
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 会收到错误消息并调整行为:
// utils/hooks.ts:2648-2668
if (result.status === 2) {
yield {
blockingError: {
blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`,
command: hook.command,
},
outcome: 'blocking' as const,
hook,
}
return
}
4.5 JSON 输出协议
当 Hook 输出以 { 开头时,系统尝试将其解析为结构化 JSON 响应。syncHookResponseSchema(types/hooks.ts:50-166)定义了丰富的输出字段:
// types/hooks.ts:50-66(简化)
export const syncHookResponseSchema = lazySchema(() =>
z.object({
continue: z.boolean().optional(), // 是否继续
suppressOutput: z.boolean().optional(), // 隐藏 stdout
stopReason: z.string().optional(), // continue=false 时的原因
decision: z.enum(['approve', 'block']).optional(), // 权限决策
reason: z.string().optional(), // 决策原因
systemMessage: z.string().optional(), // 警告消息
hookSpecificOutput: z.union([ // 事件特定输出
// PreToolUse: permissionDecision, updatedInput, additionalContext
// PostToolUse: additionalContext, updatedMCPToolOutput
// SessionStart: additionalContext, initialUserMessage, watchPaths
// PermissionRequest: decision (allow/deny + updatedInput)
// ...
]).optional(),
}),
)
特别值得注意的是 updatedInput 字段 —— PreToolUse Hook 可以通过它修改工具的输入参数。这意味着 Hook 不仅可以拦截,还可以改写 AI 的工具调用。
五、异步 Hook:后台执行与唤醒机制
Hooks 支持两种异步模式,由配置字段 async 和 asyncRewake 控制。
5.1 配置级异步
当 hook.async = true 时,Hook 在启动后立即后台化(utils/hooks.ts:995-1030):
// utils/hooks.ts:995-1030(简化)
if ((hook.async || hook.asyncRewake) && !forceSyncExecution) {
// 先写入 stdin
child.stdin.write(jsonInput + '\n', 'utf8')
child.stdin.end()
// 后台化
const backgrounded = executeInBackground({
processId, hookId, shellCommand,
asyncResponse: { async: true, asyncTimeout: hookTimeoutMs },
hookEvent, hookName, command: hook.command,
asyncRewake: hook.asyncRewake,
})
if (backgrounded) {
return { stdout: '', stderr: '', output: '', status: 0, backgrounded: true }
}
}
5.2 协议级异步
Hook 也可以在运行时动态切换为异步。当 Hook 的 stdout 第一行输出 {"async": true} 时,系统检测到后自动后台化(utils/hooks.ts:1112-1164):
// utils/hooks.ts:1117-1143(简化)
if (!initialResponseChecked) {
const firstLine = firstLineOf(stdout).trim()
if (!firstLine.includes('}')) return // 等待完整 JSON
initialResponseChecked = true
const parsed = jsonParse(firstLine)
if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) {
const backgrounded = executeInBackground({...})
if (backgrounded) {
shellCommandTransferred = true
asyncResolve?.({ stdout, stderr, output, status: 0 })
}
}
}
5.3 asyncRewake:后台执行 + 唤醒模型
asyncRewake 是一个更高级的模式(utils/hooks.ts:205-246)。Hook 在后台运行,但如果以 exit code 2 退出,会通过 enqueuePendingNotification() 唤醒模型:
// utils/hooks.ts:218-243(简化)
void shellCommand.result.then(async result => {
await new Promise(resolve => setImmediate(resolve)) // 等 I/O 排空
const stdout = await shellCommand.taskOutput.getStdout()
const stderr = shellCommand.taskOutput.getStderr()
shellCommand.cleanup()
if (result.code === 2) {
enqueuePendingNotification({
value: wrapInSystemReminder(
`Stop hook blocking error from command "${hookName}": ${stderr || stdout}`,
),
mode: 'task-notification',
})
}
})
5.4 Async Hook Registry
后台化的 Hook 被注册到全局的 AsyncHookRegistry(utils/hooks/AsyncHookRegistry.ts)。pendingHooks 是一个 Map<string, PendingAsyncHook>:
// utils/hooks/AsyncHookRegistry.ts:28
const pendingHooks = new Map<string, PendingAsyncHook>()
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)的核心逻辑:
// utils/hooks/execPromptHook.ts:62-100(简化)
const response = await queryModelWithoutStreaming({
messages: messagesToQuery,
systemPrompt: asSystemPrompt([
`You are evaluating a hook in Claude Code.
Your response must be a JSON object matching one of the following schemas:
1. If the condition is met, return: {"ok": true}
2. If the condition is not met, return: {"ok": false, "reason": "..."}`,
]),
thinkingConfig: { type: 'disabled' as const },
options: {
model: hook.model ?? getSmallFastModel(),
outputFormat: {
type: 'json_schema',
schema: {
type: 'object',
properties: { ok: { type: 'boolean' }, reason: { type: 'string' } },
required: ['ok'],
additionalProperties: false,
},
},
},
})
关键设计点:
- 使用
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):
// utils/hooks.ts:2826-2847
switch (result.permissionBehavior) {
case 'deny':
// deny 始终优先
permissionBehavior = 'deny'
break
case 'ask':
// ask 优先于 allow,但不优先于 deny
if (permissionBehavior !== 'deny') {
permissionBehavior = 'ask'
}
break
case 'allow':
// allow 只在无其他决策时生效
if (!permissionBehavior) {
permissionBehavior = 'allow'
}
break
case 'passthrough':
// passthrough 不设置权限行为
break
}
优先级链:deny > ask > allow > passthrough。这意味着:
- 如果任何一个 Hook 说
deny,操作一定被阻止 - 如果没有
deny但有ask,会弹出确认对话框 - 只有在所有 Hook 都同意(或 passthrough)时,才会自动
allow
这是一个安全优先的设计 —— 最严格的决策获胜。
八、安全边界:三级管控策略
Hooks 系统的安全管控由 hooksConfigSnapshot.ts 实现,分为三个层级:
8.1 三级开关
// utils/hooks/hooksConfigSnapshot.ts:62-88
// 1. shouldAllowManagedHooksOnly():只允许管理策略中的 Hook
export function shouldAllowManagedHooksOnly(): boolean {
const policySettings = getSettingsForSource('policySettings')
if (policySettings?.allowManagedHooksOnly === true) return true
// 非管理设置的 disableAllHooks 被解读为"只保留管理 Hook"
if (getSettings_DEPRECATED().disableAllHooks === true &&
policySettings?.disableAllHooks !== true) {
return true
}
return false
}
// 2. shouldDisableAllHooksIncludingManaged():完全禁用所有 Hook
export function shouldDisableAllHooksIncludingManaged(): boolean {
return getSettingsForSource('policySettings')?.disableAllHooks === true
}
层级关系:
- 正常模式:所有来源的 Hook 都执行
- managedOnly 模式:只执行
policySettings(企业管理策略)中的 Hook,用户/项目级 Hook 被跳过 - disableAll 模式:所有 Hook 都不执行(包括管理 Hook)
8.2 快照隔离
Hook 配置在 setup() 阶段被捕获为快照(captureHooksConfigSnapshot()),默认按快照执行。快照会在特定时机通过 updateHooksConfigSnapshot() 刷新(worktree 切换、设置文件变更),但不会因任意的文件修改而实时更新。这种"快照 + 定点刷新"的设计防止了 mid-session 的随意配置注入:
// utils/hooks/hooksConfigSnapshot.ts:95-97
export function captureHooksConfigSnapshot(): void {
initialHooksConfig = getHooksFromAllowedSources()
}
// utils/hooks/hooksConfigSnapshot.ts:104-112
export function updateHooksConfigSnapshot(): void {
resetSettingsCache() // 确保从磁盘读取最新设置
initialHooksConfig = getHooksFromAllowedSources()
}
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 的主要使用场景。
8.4 三种执行身份的 permission handler
同一个工具调用,谁来问"允不允许"?答案因身份而异。hooks/toolPermission/handlers/ 下挂了三个 handler,对应三种身份:你自己坐在终端前用的主 Agent、Coordinator 模式下的 worker、Swarm 里的 worker。三套流程的时序长得不一样:
主 Agent 走的是"赛跑"模式。权限 hook 在后台跑、bash 命令分类器在后台跑、用户那边的弹窗也同时弹出来 —— 谁先有答案谁说了算。关键就在那个一次性 resolver 上:
// hooks/toolPermission/handlers/interactiveHandler.ts:70(节选)
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)
resolveOnce 是赛道终点的裁判 —— 哪条线先到,其它线后续的 resolveOnce(...) 调用都自动作废,避免重复 resolve 一个 Promise。
Coordinator worker 走的是"接力"模式。同样这几路检查,这里不让它们一起跑,而是按顺序 await:
// hooks/toolPermission/handlers/coordinatorHandler.ts:31-46(节选)
// 1. Try permission hooks first (fast, local)
const hookResult = await ctx.runHooks(permissionMode, suggestions, updatedInput)
if (hookResult) return hookResult
// 2. Try classifier (slow, inference -- bash only)
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) return classifierResult
// 3. Neither resolved -- fall through to dialog below.
先 hook(本地、快),给不出再 classifier(推理、慢、只对 bash),还给不出再落到交互弹窗。worker 进程没什么并发预算,串行反而省事。
Swarm worker 走的是"打回主站"模式。worker 本地没有用户可问,它把请求打包成一封邮件经 mailbox 发回主 session,自己挂起等回信:
// hooks/toolPermission/handlers/swarmWorkerHandler.ts:49-57(节选)
// 先试 classifier 快路径 —— 能本地放行就不必跨进程
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) return classifierResult
// 否则 createPermissionRequest + sendPermissionRequestViaMailbox + 等 leader 回信
注意第 51-52 行:bash 分类器作为"省一次往返"的快路径仍然保留 —— 能本地裁决的就不打扰主站。
这三套流程的共同地基是 第七节 描述的 hook 合并规则 —— deny > ask > allow 的优先级链。"权限 hook 之间怎么合并"这一段对三种身份是统一的;差别只在 hook 给不出答案之后由谁来兜底:是弹窗、是顺序接力,还是远端裁决。
附:本节涉及源码 —— hooks/toolPermission/handlers/interactiveHandler.ts:57-72、hooks/toolPermission/handlers/coordinatorHandler.ts:26-62、hooks/toolPermission/handlers/swarmWorkerHandler.ts:40-80、hooks/toolPermission/PermissionContext.ts 的 createResolveOnce。
九、Frontmatter Hook 注册
Agent 和 Skill 可以在 markdown frontmatter 中定义 Hook,这些 Hook 被注册为 Session 级别的临时 Hook。registerFrontmatterHooks()(utils/hooks/registerFrontmatterHooks.ts:18-67)处理注册逻辑:
// utils/hooks/registerFrontmatterHooks.ts:18-67(简化)
export function registerFrontmatterHooks(
setAppState, sessionId, hooks, sourceName, isAgent = false,
): void {
for (const event of HOOK_EVENTS) {
const matchers = hooks[event]
if (!matchers) continue
// 关键:Agent 的 Stop hook 自动转换为 SubagentStop
let targetEvent = event
if (isAgent && event === 'Stop') {
targetEvent = 'SubagentStop'
}
for (const matcherConfig of matchers) {
for (const hook of matcherConfig.hooks) {
addSessionHook(setAppState, sessionId, targetEvent, matcher, hook)
}
}
}
}
一个巧妙的设计: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):
// utils/hooks.ts:2036-2067
// Fast-path: all hooks are internal callbacks (sessionFileAccessHooks,
// attributionHooks). Measured: 6.01µs → ~1.8µs per PostToolUse hit (-70%).
if (matchingHooks.every(m => m.hook.type === 'callback' && m.hook.internal)) {
for (const [i, { hook }] of matchingHooks.entries()) {
if (hook.type === 'callback') {
await hook.callback(hookInput, toolUseID, signal, i, context)
}
}
return // 跳过 telemetry、span、progress 等开销
}
10.2 惰性 JSON 序列化
hookInput 的 JSON 序列化只在需要时执行一次,且多个 Hook 共享(utils/hooks.ts:2124-2140):
// utils/hooks.ts:2124-2140
let jsonInputResult: { ok: true; value: string } | { ok: false; error: unknown } | undefined
function getJsonInput() {
if (jsonInputResult !== undefined) return jsonInputResult
try {
return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) })
} catch (error) {
return (jsonInputResult = { ok: false, error })
}
}
10.3 事件存在性快速检查
hasHookForEvent()(utils/hooks.ts:1582-1593)提供了一个轻量级的存在性检查,用于跳过无 Hook 配置时的 createBaseHookInput() 和 getMatchingHooks() 开销:
// utils/hooks.ts:1582-1593
function hasHookForEvent(hookEvent, appState, sessionId): boolean {
const snap = getHooksConfigFromSnapshot()?.[hookEvent]
if (snap && snap.length > 0) return true
const reg = getRegisteredHooks()?.[hookEvent]
if (reg && reg.length > 0) return true
if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true
return false
}
十一、Prompt Elicitation:Hook 与用户的双向对话
一个极其精巧的特性是 Prompt Elicitation 协议(utils/hooks.ts:1060-1110)。Hook 进程可以向 stdout 输出一个特殊的 JSON 格式来向用户提问,然后通过 stdin 接收回答:
// types/hooks.ts:28-40
export const promptRequestSchema = lazySchema(() =>
z.object({
prompt: z.string(), // 请求 ID
message: z.string(), // 显示给用户的问题
options: z.array(z.object({ // 选项列表
key: z.string(),
label: z.string(),
description: z.string().optional(),
})),
}),
)
当 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)。
下一章预告
第 21 章:Skill / Plugin / Output Style 三扩展点 — 基于源码理解扩展点
我们站在扩展开发者的视角,看 Skill、Plugin、Output Style 三条扩展路径如何让用户把对源码的理解反过来用。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)