学习工作台专题学习
工作台/Column Docs/深入 Claude Code 源码
返回专题目录
2026年7月11日14 分钟

第 20 章Hooks 系统用 Shell 命令扩展 AI 行为

墨圆
墨圆团队发布于 2026年7月11日

来源与授权

本文来自 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 数组定义:

TYPESCRIPT
// 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

TYPESCRIPT
// 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())),
)

一个实际配置示例:

JSON
{
  "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):

TYPESCRIPT
// 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
  ])
})
类型执行方式典型场景
commandspawn() 启动 Shell 进程运行 lint/test/格式化脚本
prompt调用 LLM(默认 getSmallFastModel(),通常为 Haiku)评估条件"检查代码是否有安全漏洞"
agent启动多轮 Agent 对话验证"验证单元测试是否通过"
httpHTTP POST 到指定 URL调用外部审批/CI 系统

除了这四种可持久化类型,运行时还有两种内存级类型:

  • callback:TypeScript 回调函数,用于 SDK 注册和内部监控(如文件访问追踪、commit attribution)
  • function:Session 级 TypeScript 回调,用于结构化输出强制校验

2.2 Matcher 匹配逻辑

matcher 字段控制 Hook 何时触发。matchesPattern() 函数(utils/hooks.ts:1346-1381)支持三种模式:

TYPESCRIPT
// 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 }
}

三种匹配模式:

  1. 精确匹配"Write" — 精确匹配工具名
  2. 多值匹配"Write|Edit" — 管道分隔的多个精确值
  3. 正则匹配"^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):

TYPESCRIPT
// 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 配置:

TYPESCRIPT
// 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.tsgetRegisteredHooks() 获取。

来源 3 — Session Hook:Agent 和 Skill 通过 frontmatter 定义的 Hook,在运行时注册到 AppState.sessionHooks 中(一个 Map<string, SessionStore>)。关键的设计决策是:Session Hook 使用 Map 而非 Recordutils/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)。去重的关键设计是按来源命名空间隔离

TYPESCRIPT
// 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 链路 —— 主流程不等它们跑完:

TYPESCRIPT
// 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 建议、抽取本轮值得记住的事实、触发"自动复盘"。注意 extractMemoriesautoDream 都拒绝子 Agent 触发(!toolUseContext.agentId),因为这是 session 级的脑回路,跟子任务无关。

第二段才是真正运行用户配置的 Stop hook,事件名按身份分叉 —— 主 session 触发 Stop,子 Agent 触发 SubagentStop,同一段执行引擎,两个挂钩点:

TYPESCRIPT
// 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,长这样:

TYPESCRIPT
// 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 放在同一章讲,是因为两套东西名字一样、语义其实也一样:在某个时间点挂一段自定义行为。区别只在挂哪儿:

类别挂在哪触发时机例子
生命周期 hookquery loop 的事件总线Shell、LLM、HTTP、Stop……PreToolUse 拦下危险 bash
notifs hookReact 渲染循环组件挂载 / 状态变更启动时弹一次 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):

TYPESCRIPT
// utils/hooks.ts:790-791
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL  // 默认 'bash'
const isPowerShell = shellType === 'powershell'

两种 Shell 的 spawn 方式完全不同:

TYPESCRIPT
// 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):

TYPESCRIPT
// 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)处理:

TYPESCRIPT
// 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 会收到错误消息并调整行为:

TYPESCRIPT
// 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 响应。syncHookResponseSchematypes/hooks.ts:50-166)定义了丰富的输出字段:

TYPESCRIPT
// 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 支持两种异步模式,由配置字段 asyncasyncRewake 控制。

5.1 配置级异步

hook.async = true 时,Hook 在启动后立即后台化(utils/hooks.ts:995-1030):

TYPESCRIPT
// 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):

TYPESCRIPT
// 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() 唤醒模型:

TYPESCRIPT
// 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 被注册到全局的 AsyncHookRegistryutils/hooks/AsyncHookRegistry.ts)。pendingHooks 是一个 Map<string, PendingAsyncHook>

TYPESCRIPT
// 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)的核心逻辑:

TYPESCRIPT
// 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):

TYPESCRIPT
// 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 三级开关

TYPESCRIPT
// 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 的随意配置注入:

TYPESCRIPT
// 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/1610.0.0.0/8172.16.0.0/12192.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 上:

TYPESCRIPT
// hooks/toolPermission/handlers/interactiveHandler.ts:70(节选)
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)

resolveOnce 是赛道终点的裁判 —— 哪条线先到,其它线后续的 resolveOnce(...) 调用都自动作废,避免重复 resolve 一个 Promise。

Coordinator worker 走的是"接力"模式。同样这几路检查,这里不让它们一起跑,而是按顺序 await:

TYPESCRIPT
// 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,自己挂起等回信:

TYPESCRIPT
// 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-72hooks/toolPermission/handlers/coordinatorHandler.ts:26-62hooks/toolPermission/handlers/swarmWorkerHandler.ts:40-80hooks/toolPermission/PermissionContext.tscreateResolveOnce


九、Frontmatter Hook 注册

Agent 和 Skill 可以在 markdown frontmatter 中定义 Hook,这些 Hook 被注册为 Session 级别的临时 Hook。registerFrontmatterHooks()utils/hooks/registerFrontmatterHooks.ts:18-67)处理注册逻辑:

TYPESCRIPT
// 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):

TYPESCRIPT
// 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):

TYPESCRIPT
// 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() 开销:

TYPESCRIPT
// 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 接收回答:

TYPESCRIPT
// 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 (求一颗免费的小星星)