第 11 章 第四部分:工程细节

Hooks 与生命周期

PreToolUse / PostToolUse / Notification 钩子系统、事件驱动的扩展模型、用户自定义脚本的安全执行机制

Claude Code Hooks 生命周期 事件驱动 扩展机制

11.1 为什么需要 Hooks

工具系统(第 5 章)定义了 Claude Code 能做什么,权限系统(第 7 章)定义了它被允许做什么。但用户可能有更细粒度的需求:

  • 在每次文件写入之前运行 lint 检查
  • 在 Bash 命令执行之后记录审计日志
  • 在特定工具调用时触发自定义通知
  • 在 Claude Code 发送通知时执行额外操作

Hooks 系统提供了这种扩展能力——在工具执行的生命周期中插入用户自定义的脚本。

11.2 Hook 事件全景

官方 Hooks 参考文档的 “Hook lifecycle” 表当前列出 33 个事件(快照日期 2026-09-10)。此前本章写的是”28+ 种”——这个表述的问题在于无法对账:读者既不知道确切数字,也无法判断清单是否落后。下面按本书的分组方式逐一列出,并在最后给出对账。

需要注意:33 是官方逐字列出的事件数,不是”至少”或”约”。数量随版本变化,所以记录快照日期比记录数字本身更重要。这个数字也从最初的三种增长而来——Hooks 从”工具执行前后的两个插槽”长成了一套覆盖会话、权限、子代理、上下文、模型、显示的完整事件总线。按类别分组如下:

工具生命周期类

Hook 事件执行时机典型用途
PreToolUse工具执行前检查、拦截、参数修改
PostToolUse工具执行后审计日志、后处理
PostToolUseFailure工具执行失败后错误通知、重试策略
PostToolBatch一批工具调用全部结束后、下一次请求模型之前注入依赖”这一批跑了哪些工具”的上下文

会话生命周期类

Hook 事件执行时机典型用途
SessionStart会话启动时环境初始化、上下文注入
SessionEnd会话结束时清理、归档
Setup初始化配置时项目级配置检查

模型切换类(v2.1.251+)

这是 Hooks 系统中第一个监视模型本身的类别。此前所有事件监视的都是”模型做了什么”(PreToolUse 看 Bash 命令、PermissionRequest 看请求、PreCompact 看 transcript),而 PreModelSwitch / PostModelSwitch 监视的是”用哪个权重在回答”。

Hook 事件执行时机可阻断典型用途
PreModelSwitch应用模型切换之前✅ 可阻断成本闸门(阻止升档)、企业授权范围(只允许特定模型)、敏感仓库阻止降级
PostModelSwitch会话模型变化之后❌ 不可阻断审计”哪个模型写了这段代码”、成本追踪、按模型下发不同指引

触发范围需要精确界定——这一点上二手资料普遍写错,务必以官方为准。PreModelSwitch 只在你或客户端主动请求的切换时触发:

  • /model <name> 与 /model 选择器
  • Option+P / Alt+P 模型选择器
  • /config 里的 Model 设置
  • 开启 fast mode 且该操作会改变会话模型时
  • Agent SDK 宿主的 set_model 请求,或 apply_flag_settings 请求中的模型变更

⚠️ 官方明确排除的两类切换:自动模型回退(automatic model fallback)和恢复会话时还原模型,Claude Code 不会运行 PreModelSwitch——这些是它自己造成的切换,只会到达 PostModelSwitch。官方原话为 “Claude Code doesn’t run PreModelSwitch hooks for switches it makes on its own… Those changes reach PostModelSwitch only.”

这个区分有实际后果:如果你的成本闸门只挂在 PreModelSwitch 上,那么过载自动回退到更贵/更便宜的模型这条路是完全不受控的。要覆盖它必须改用 PostModelSwitch 做审计与事后纠正。还有一个更细的边界:fallback model chain 里的模型只服务一个 turn、不改变会话模型,因此 PostModelSwitch 也不会触发——单个 turn 的模型替换目前没有任何 hook 可见。

stdin 字段与 matcher 的两个特殊规则:

  1. 切换类事件收到的是 from_model / to_model,而不是单个 model 字段——官方明确只有 SessionStart 才会收到 model。原因很直白:一次迁移无法用单个 model 字段描述。另注意没有 $CLAUDE_MODEL 环境变量;$ANTHROPIC_MODEL 可以读但不会随切换改变,不能用它判断当前模型。
  2. matcher 匹配的是 to_model 推导出的规范名(canonical name),忽略 [1m] 后缀。别名(如 opus)、带日期的 ID、以及 Amazon Bedrock 这类供应商专属 ID,都会归到同一个规范名,所以 claude-opus-5 能覆盖 Opus 5 的所有写法。matcher 可写精确名、| 分隔列表(claude-opus-4-6|claude-opus-5)或正则(.*opus.*)。
  3. 当 Claude Code 无法确定目标规范名时(例如只有你的 LLM gateway 认识的自定义模型 ID),它会无视 matcher、运行所有 PreModelSwitch hook。因此一个要阻断的 hook 必须自行检查输入里的 to_model,不能只依赖 matcher。

权限类

Hook 事件执行时机典型用途
PermissionDenied权限被拒绝时审计告警
PermissionRequest权限请求时动态授权策略

Agent 协作类

Hook 事件执行时机典型用途
SubagentStart子代理启动时资源分配监控
SubagentStop子代理停止时结果收集、状态同步
TeammateIdle队友空闲时任务再分配
TaskCreated任务创建时依赖检查
TaskCompleted任务完成时进度追踪

上下文管理类

Hook 事件执行时机典型用途
PreCompact上下文压缩前保留关键信息
PostCompact上下文压缩后验证信息完整性
CwdChanged工作目录变更时项目上下文切换
FileChanged文件变更时自动格式化、测试触发
ConfigChange配置变更时策略热更新
InstructionsLoaded指令加载时指令验证
WorktreeCreateWorktree 创建时环境初始化
WorktreeRemoveWorktree 移除时清理
DirectoryAdded会话中通过 /add-dir 新增工作目录后准备新仓库(如安装依赖)

DirectoryAdded 有三个不触发的边界,值得记住:用 --add-dir 启动参数传入的目录不触发(由 SessionStart 覆盖);在 /permissions 的 Workspace 标签页添加的目录不触发;添加一个已经是或位于现有工作目录之内的目录不触发。它在沙箱与权限状态刷新之后才触发,所以 hook 里的沙箱工具已经能看到新目录。

用户交互类

Hook 事件执行时机典型用途
UserPromptSubmit用户提交提示时输入预处理、过滤
UserPromptExpansion用户键入的 slash command 展开为 prompt 之前阻止特定命令被直接调用、为某个 skill 注入上下文、记录命令调用
NotificationClaude Code 发送通知时通知转发、自定义提醒
Stop会话停止时最终清理
StopFailure停止失败时错误恢复
Elicitation请求用户输入时日志
ElicitationResult收到用户输入时输入验证

UserPromptExpansion 的价值在于它补上了 PreToolUse 够不到的那条路径:一个匹配 Skill 工具的 PreToolUse hook 只在 Claude 调用该工具时触发,而用户直接键入 /skillname 会绕过 PreToolUse。UserPromptExpansion 正是在这条直达路径上触发的。它匹配的是 command_name——比如一个匹配 deploy 的 hook 可以在没有审批文件时阻止 /deploy。

Notification 此前只出现在 11.4 的配置示例和”最常用的 5 种 Hook”里,缺席全部分类表——这是本章清单长期落后的一项,现已补入。

显示控制类

Hook 事件执行时机典型用途
MessageDisplayassistant 文本输出到显示层之前PII 脱敏、引用注入、审计追踪

MessageDisplay(v2.1.152,2026-05-27)是特殊的显示层 hook——它在模型输出文本渲染到终端/IDE 面板前触发,通过返回 displayContent 替换显示文本,但 transcript 和模型上下文保留原始内容。不支持 matcher,每次 assistant message 都触发。

性能警告:每次 assistant turn 都触发,延迟敏感——保持 <50ms,用 sed/awk 而非网络调用。

合并风险:多个 hook 返回 displayContent 时合并顺序未定义——建议只注册一个 rewriting hook。

典型场景:

  • 屏幕共享时 PII 脱敏(实时替换敏感信息显示)
  • 引用注入(在显示层自动添加文档引用)
  • Banner 注入(在输出前添加合规声明)
  • 审计追踪(在显示内容中嵌入溯源标记)

清单对账(对照官方 33 个事件):工具生命周期 4 + 会话生命周期 3 + 模型切换 2 + 权限 2 + Agent 协作 5 + 上下文管理 9 + 用户交互 7 + 显示控制 1 = 33。与官方清单逐项一致,无缺项。下次版本更新后重做这道加法,是判断本章是否落后的最快方法——把模糊的”28+“换成可对账的数字,本身就是一种维护机制。

换一个轴:按运行时形态切分。 上面八组是按功能分的,回答”这个事件管什么事”;官方生命周期图暗含了另一种切法,回答的是“这个事件能不能挡住主流程”——写 hook 时后者往往更先用到:

形态事件意味着什么
per-turn loop 内SessionStart UserPromptSubmit UserPromptExpansion PreToolUse PermissionRequest PostToolUse PostToolUseFailure PostToolBatch SubagentStart SubagentStop TaskCreated TaskCompleted Stop / StopFailure TeammateIdle PreCompact PostCompact SessionEnd处在主循环的路径上,其返回会直接影响这一轮怎么走
嵌套在 MCP 工具执行内Elicitation ElicitationResult只在 MCP 服务器请求用户输入时出现,是 33 个事件里唯一一对”工具内部”的事件(详见 11.4.2)
standalone asyncWorktreeCreate WorktreeRemove Notification ConfigChange InstructionsLoaded CwdChanged FileChanged DirectoryAdded异步旁路,适合做观测与通知,不应用来阻塞
standalone sequentialPreModelSwitch在模型切换前同步执行,也是唯一一个超时 fail-closed 的事件(见 11.5)
display-onlyMessageDisplay只影响显示文本,transcript 与模型上下文保留原内容

⚠️ 注意 PermissionDenied 不在 PermissionRequest 的响应路径上,而是 auto 模式拒绝时的独立分支——它是 33 个事件里唯一一个”由另一个事件的特定结果触发”的事件。

最常用的 5 种 Hook 是 PreToolUse、PostToolUse、Notification、UserPromptSubmit 和 SessionStart——它们覆盖了绝大多数扩展需求。

企业级 Inference Hooks(v2026-08-05)

2026 年 8 月 5 日,Anthropic 发布了 Claude Enterprise 专用的 Inference Hooks——一种服务器端钩子机制,与上述客户端 Hooks 形成互补:

维度客户端 Hooks(本章主体)Inference Hooks
部署位置客户端 CLI / VS Code 扩展Anthropic 服务器端 / API Gateway 层
触发时机工具调用前后、会话生命周期Prompt 到达模型前(PreModelInference)、模型响应后(PostModelInference,roadmap)
控制粒度工具级 + 事件级请求级(二进制判定:allow/deny)
适用场景开发工作流自动化DLP 数据防泄露、合规审批、审计日志注入
覆盖范围Claude Code 客户端claude.ai + Cowork + Claude Code(组织级统一配置)
配置权限.claude/settings.json 本地文件组织 organization:manage 权限(Admin/Owner/Primary owner)

核心工作流:企业配置一个 HTTPS 安全服务器端点 → Claude 在每次推理前将对话全文(PromptFrame)签名发送到该端点 → 服务器返回 allow/deny → deny 时请求不抵达模型。此功能目前处于 beta 阶段,仅面向 Claude Enterprise 客户,已集成 Palo Alto Networks、Netskope、Proofpoint、Zscaler 等 DLP 厂商。

架构意义:Inference Hooks 将安全边界从”客户端本地”推进到”模型推理之前”——以前 Claude Code 的 Hooks 只能拦截用户的 shell 命令和文件操作,Inference Hooks 可以拦截 prompt 本身。两者是互补非替代关系。

Hook 配置在 .claude/settings.json 中:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "FileWrite",
        "command": "eslint --fix ${file}"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "command": "logger 'Bash executed: ${command}'"
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "command": "notify-send '${title}' '${body}'"
      }
    ]
  }
}

matcher 支持工具名称匹配——空字符串匹配所有工具/通知,具体名称匹配特定工具。

11.3 Hook 的执行流程

11.3.1 五种 Hook 类型

Hook 类型从最初的单一 command 扩展为 5 种,每种类型有不同的执行模型:

类型执行方式输出格式适用场景
command执行 shell 命令stdout 作为返回外部工具集成、脚本自动化
httpPOST 到 webhook 端点HTTP 响应体远程服务通知、企业集成
mcp_tool直接调用 MCP 工具工具返回值无需 shell 的结构化操作
prompt用 Claude 自身评估判断结果上下文感知的条件判断
agent生成子代理多步验证验证报告复杂条件的多步推理检查

特别值得注意的是 prompt 和 agent 两种类型——它们代表了”用 AI 控制 AI”的新范式。prompt 类型让 Claude 自己判断某个条件是否满足(例如”这段代码是否符合安全规范”),agent 类型则更进一步,生成一个子代理执行多步验证(例如”检查这个 PR 的所有文件是否符合项目规范”)。这与 Hooks 作为”确定性执行层”的定位形成了有趣的张力:Hooks 的一半是确定性的脚本执行,另一半是 AI 的弹性判断。

Hook 在工具执行流程中的位置(回顾第 5 章的工具执行生命周期):

1. Schema 验证     → Zod.safeParse(input)
2. 业务规则验证    → tool.validateInput(input)
3. ★ PreToolUse Hook → 用户自定义前置钩子 ★
4. 权限检查       → checkPermissionsAndCallTool()
5. 工具执行       → tool.call(input)
6. 结果格式化     → mapToolResultToToolResultBlockParam()
7. ★ PostToolUse Hook → 用户自定义后置钩子 ★
8. 结果返回       → yield 给 QueryEngine

注意 PreToolUse Hook 在权限检查之前执行——这意味着 Hook 可以在权限系统之外增加额外的控制层。

11.4 Hook 的权限决策

PreToolUse Hook 可以返回权限决策,直接影响工具是否被执行:

Hook 返回效果使用场景
allow跳过后续权限检查,直接允许企业白名单系统
deny直接拒绝,不执行工具外部审计系统一票否决
无返回 / 其他继续正常的权限检查流程仅做日志记录

退出码协议。command 类型 Hook 的退出码与权限决策有明确映射:

退出码含义权限效果
0成功继续等同于 allow
2阻塞等同于 deny(仅 PreToolUse)
其他非零错误但不阻塞继续正常权限检查

匹配器增强。matcher 支持正则表达式(如 "Bash(git *)" 匹配所有 git 开头的 Bash 命令)、if 条件过滤、once: true 仅执行一次——这些增强使 Hook 配置更加灵活和精确。

matcher 有两个不写在文档标题里的坑,都已在 2026 年 9 月被修掉。

第一个是被匹配字段缺失时的行为,与”空 matcher 匹配一切”是两回事:SubagentStop 上带特定 matcher 的 hook,此前会对每一个 agent type 为空的停止中的子代理触发(v2.1.275 修复)。也就是说,matcher 写得很具体并不代表触发面就窄——当被匹配的那一栏是空值时,“具体”会被解释成”匹配”。写 matcher 时要想清楚目标字段可能为空的场合。

第二个是 matcher 名与 JavaScript 原型链的相撞:hook matcher 若以继承来的对象属性命名(如 __proto__ 或 constructor),/hooks 交互菜单会直接崩溃(v2.1.271 修复)。这条对一本”源码解剖”的书尤其有信息量——它说明 matcher 名在实现上被当成了对象键使用,__proto__ 因此不是普通字符串。同源的问题在同一时期还出现在别处:v2.1.277 修了 /plugin → Installed 与 /skills 在 skill 或 legacy command 取名为 constructor、toString 这类内建属性时崩溃。一个把用户输入当键用的实现,在所有这类入口上都要防同一件事。

这意味着 Hook 可以作为一个外部权限源——比如连接到企业的安全审计系统,在 Claude Code 内置权限系统之外增加一层控制。这对于需要合规审计的企业场景特别有价值。

输出写错时会怎样。上面讨论的都是”hook 正确返回时如何解释”,还有一个同样重要的问题:hook 输出格式非法时会发生什么。11.5 节会讲到超时、进程隔离、输出限制、配置源四类边界,但在此之前先补上更常见的一类——输出本身不合格。

情形旧行为现行为(v2.1.248 起)
hook 往 stdout 打印一个不是合法 JSON 的 {…} 对象被静默当作纯文本处理报为 hook 错误并附带解析信息
后台会话中 PermissionRequest / PreToolUse hook 打印无效答案静默等待claude agents 行指明是哪个 hook 及其 schema 错误

第二条值得单独强调。hook 输出错误在后台会话中的表现是挂起,不是报错。一个跑在后台的 agent 卡住不动、日志里没有任何异常,最可能的原因不是网络或模型,而是某个 hook 吐出了一句格式不对的 JSON。这类故障之所以难查,是因为它在现象上与”模型在思考”完全一样。诊断它需要与 11.5 节的超时机制配合使用:先确认不是 hook 挂起,再排查其他方向。

配套的还有 v2.1.246 的一条小修复:hook 错误信息里的 ${CLAUDE_PLUGIN_ROOT} 曾以字面量显示,现在解析为真实插件路径。这看起来只是显示问题,但当错误信息是你唯一的线索时,一个没被展开的变量会让整条信息失去指向性。

11.4.1 输出改写:第四种权限模式

除了 allow/deny/无返回三种权限决策模式,PostToolUse Hook 在 v2.1.121 起引入了输出改写模式——不改变执行权限,但改写模型视角的工具输出:

模式机制效果
允许allow跳过权限检查,直接执行
拒绝deny阻止执行
默认无返回继续正常权限检查
改写updatedToolOutput执行后替换模型看到的输出

updatedToolOutput 字段允许 Hook 将工具真实输出替换后再呈现给模型——适用于所有工具类型,不仅仅是 Bash。Transcript 中保留原始输出,只有模型上下文中被改写。

典型场景:

  • 密钥脱敏:kubectl get secrets 输出中扫描并脱敏 AWS/Azure 密钥后,再将脱敏版呈现给模型
  • 输出压缩:50KB 的测试输出压缩为一段摘要,减少上下文占用
  • 跨平台规范化:将 macOS 风格路径转为 Linux 风格,使模型在跨平台协作中不受路径格式干扰

与 additionalContext 的区别:updatedToolOutput 是替换模型看到的工具输出,而 additionalContext 是追加额外信息——前者控制模型看到什么,后者补充模型还应该知道什么。

风险警告:(1) 脱敏后模型无法看到真实值,可能影响后续推理——需确保脱敏的字段后续不需要;(2) 多 hook 同时返回 updatedToolOutput 存在竞态,建议每个 matcher 只注册一个 rewriting hook;(3) 静默错误屏蔽——如果 hook 过滤了 stderr 中的警告信息,模型可能做出错误决策。

11.4.2 Elicitation:退出码 2 通则的唯一例外

上面的退出码协议有一个必须先记住的边界。官方 hooks 文档对退出码 2 的通则是:“Exit 2 means a blocking error… Claude Code still reads any valid JSON output on stdout.” 也就是说,即便退出码已经是 2,stdout 上的 JSON 仍会被读(11.4.1 的 updatedToolOutput 与 decision 都依赖这条)。

但 Elicitation 与 ElicitationResult 是这 33 个事件里唯一的例外:“On Elicitation and ElicitationResult, an exit-2 hook’s hookSpecificOutput is ignored.” 读者若按通则给这两个事件写退出码 2 的 hook,会以为 content 里的表单字段还能被读到——读不到。

两个事件的语义与 block 行为:

事件触发时机matcher支持 blockblock 的效果
ElicitationMCP 服务器在工具调用中请求用户输入时MCP 服务器名是拒绝该次 elicitation
ElicitationResult用户响应后、回传服务器之前同上是阻断响应(action 变为 decline)

hookSpecificOutput 支持 action(accept / decline / cancel)与 content(前者为表单字段值,后者可改写)。⚠️ {"decision":"block"} 在这两个事件上此前是被静默忽略的——v2.1.284 才修好,现在它的效果等同退出码 2。也就是说,在 v2.1.284 之前,一个写了 block 的 Elicitation hook 什么也不会发生。配套的 Notification 事件同期新增了四个 type:elicitation_dialog、elicitation_url_dialog、elicitation_complete、elicitation_response。协议侧的对应物是第 10 章 10.2.1 的 MRTR(SEP-2322)——MRTR 让 elicitation 在无状态服务器上成为可能,这两个 hook 让它在客户端侧可被拦截。

11.5 超时与安全

Hook 脚本有严格的安全边界:

约束机制目的
超时限制可配置的执行超时防止失控脚本阻塞 Claude Code
进程隔离子进程执行不能直接修改 Claude Code 内部状态
输出限制返回值格式约束只能通过约定的返回格式影响行为
配置源约束只从 settings.json 读取不能在运行时动态注入 Hook
前缀稳定性hook 输出进入提示前缀,跨轮次必须稳定防止 hook 输出变化导致整段 prompt cache 失效

v2.1.139 安全增强:

  • args[] 直接派生:Hook 新增 args: string[] 字段(exec form),直接 spawn 命令无需通过 shell,路径占位符不再需要转义——消除了 shell 注入风险
  • continueOnBlock 配置:PostToolUse Hook 新增 continueOnBlock: true 选项,当 Hook 拒绝操作时仍将拒绝原因反馈给 Claude 并继续执行任务——允许”软拒绝”而非”硬阻塞”

v2.1.119 duration_ms 字段。PostToolUse Hook 的 stdin JSON 新增了 duration_ms 字段——工具调用的实际耗时(毫秒)。这对于构建性能监控 Hook 是关键数据点:

{
  "tool_name": "Bash",
  "tool_input": {...},
  "tool_output": "...",
  "duration_ms": 1234
}

结合 PostToolUse Hook,可以在不侵入 Claude Code 内部的情况下实现工具调用延迟基线、慢查询检测和 SLA 告警——典型的带外监控(out-of-band monitoring)模式。

SessionStart 的恢复成本字段。v2.1.251 起,当 source 为 "resume" 或 "fork" 且 transcript 已有回复时,SessionStart hook 会额外收到四个字段:

字段含义
seconds_since_last_response距 transcript 中最后一条回复的墙钟秒数
context_tokens恢复会话的首个请求将重新发送的 prompt token 数
prompt_cache_likely_expired缓存是否可能已过期(最后一条回复超过 prompt cache 存活期,或后续压缩替换了缓存对话时为 true)
estimated_cache_write_usd把 context_tokens 写入 prompt 缓存的预估美元成本(按会话当前模型计,不含响应)

官方示例是一个在上次回复 90 分钟后被恢复的会话:seconds_since_last_response 为 5400、context_tokens 为 182340、prompt_cache_likely_expired 为 true、estimated_cache_write_usd 为 1.1396。

这组字段把 SessionStart 的能力从”注入上下文”扩展到了”在恢复发生之前判断这次恢复贵不贵”。hook 可以据此决定是否提示用户、是否先跑一次 warmup、甚至在预估成本超过阈值时要求确认。这与第 9 章的 Prompt Caching 讨论是同一件事的两面:第 9 章讲的是缓存如何影响成本,这里讲的是在成本发生前你能拿到哪些数字。官方对该改动的说明也正是”让一个判断是否要提醒你缓存问题的 hook 有真实数字可用”。

Skill 内 Hook 作用域。Skill 可以定义自己的 Hook 作用域,这些 Hook 只在 Skill 激活时生效。这为 Skill 提供了”自带安全防护栏”的能力——例如,数据库操作 Skill 可以自带”禁止 DELETE 无 WHERE 子句”的 PreToolUse Hook,且只在 Skill 激活时生效。

Skill 级工具治理。除了 Hook 作用域之外,v2.1.152 将 Skill 的管控能力扩展到工具可用性层面——从 “执行时干预” 升级为 “工具可用性治理”:

  • disallowed-tools frontmatter:Skill 可在 frontmatter 中声明 disallowed-tools,在 Skill 激活期间直接从模型的工具面板中移除指定工具——比 PreToolUse deny 更彻底,模型甚至不知道自己 “失去了” 某个工具
  • /reload-skills 命令:无需重启 Claude Code 即可重载 Skill 配置,降低开发迭代成本
  • SessionStart reloadSkills:SessionStart Hook 可返回 reloadSkills: true,在会话启动时自动刷新 Skill 配置——实现 Skill 配置热更新

这代表了 Hook 系统从 “执行时干预” 扩展到 “工具可用性治理” 的新维度:前者在工具已选中的前提下进行拦截,后者在工具选择阶段就从模型中移除工具。适用于需要严格管控的受监管环境——例如金融场景中禁止 AI 执行任何网络请求工具的 Skill。

第五类边界:前缀稳定性。新增的这一行与前面四类性质不同——前四类约束的是”hook 能做什么”,这一类约束的是”hook 输出的内容与顺序会在成本层面造成什么后果”。hook 输出不是只在当下生效的一次性副作用,它是提示前缀的一部分:它在轮次之间必须保持稳定,否则整段 prompt cache 失效。这与第 9 章”前缀稳定性 > 单次压缩量”是同一原理,只是此前本章完全没有讨论 hook 对缓存的影响。

四条官方修复把这条边界的后果具体化了:

版本问题说明
v2.1.265agent teammates 与已恢复子 Agent 在后续轮次把 SubagentStart hook 上下文与预加载 skill 移出提示前缀破坏了 prompt cache 复用
v2.1.261恢复会话时丢失 hook 输出及并行工具调用周围的上下文恢复后的请求与原始请求不一致——不只是慢,而是变了
v2.1.259阻塞型 Stop hook 让其后的那一轮丢失模型的推理内容,部分模型上还会 miss prompt cache阻塞的代价不止是等待
v2.1.247打印数 MB 错误输出的 hook 可能撑爆会话并卡在 “Prompt is too long”给”输出限制”这一约束补上了具体失效后果
v2.1.277/clear 后续接的会话(restart、--continue、--resume)在 SessionStart hook 打印了输出时丢失首条消息的一部分,导致完整的 prompt cache miss最强的一例:hook 只要”有输出”就足以打掉整段缓存,与输出内容是否变化无关
v2.1.274Stop prompt hook 每次被 block 都重发整个 prompt;现改为只发带 500 字符标签的条件名prompt 型 hook 的注入量随 block 次数线性膨胀

第三条尤其值得注意:一个阻塞型 Stop hook 的代价不只是”用户多等一会儿”,它会让下一轮丢失模型上一轮的推理内容。这条把 11.4 节”软拒绝”(continueOnBlock)的价值从体验问题提升为正确性问题——能软拒绝就别硬阻塞。

v2.1.277 这一例把边界的口径推到了最严处。 前四条都还是”hook 输出变了所以缓存失效”,读者会自然理解为”只要输出稳定就没事”。v2.1.277 说的是另一件事:/clear 之后继续的会话里,只要 SessionStart hook 打印了任何输出,首条消息的一部分就会丢失,进而造成整段 prompt cache miss。风险点从”输出是否稳定”前移到了”是否有输出”——一个只 echo 一句状态、内容永远不变的 hook,同样会触发。

v2.1.274 那条则揭示了 prompt 型 hook 特有的一类膨胀:每次被 block 都把整个 prompt 重新注入一遍,注入量随 block 次数线性增长,而这类 hook 恰恰是被设计成”不满足条件就一直拦”的——拦得越久,前缀越长。改成 500 字符的条件标签之后,重复块的体积才与 block 次数脱钩。同源的还有 v2.1.277 一条:恢复的 subagent 与 teammate 重新渲染它们已加载的 MCP 工具定义,同样会打掉那个 agent 的 prompt caching。

可观测性:失败与超限都有了新的落点。 上面讨论的失效大多要靠现象反推,2026 年 9 月下旬补上了三类直接信号:

  • v2.1.284:失败 hook 的 stderr 在 hook 同时写了 stdout 时会被 debug log 丢弃,且一个没有任何输出的失败 hook 此前什么都不记录——现在失败 hook 也会记录状态码。这条对 11.4.1 提到的”后台会话挂起”尤其关键:此前那种”日志里什么都没有”的故障,有一半是日志本身漏了。
  • v2.1.280:hook_execution_complete OpenTelemetry 事件新增输出体积与超限输出被存入文件的数量。这补上了 11.4 那条边界的另一半——超限输出不是被丢弃,而是改道落盘,且落盘数量现在是可观测的。
  • v2.1.271:SessionStart / UserPromptSubmit / PreToolUse / SessionEnd hook 运行时,spinner 会说明正在跑哪个 hook 并计时,Esc 可取消等待 SessionStart hook 的提示。执行环境的可用性同期也有两条:v2.1.281 起 claude --bg 在未通过 workspace trust 提示的目录不再直接跑项目 hook(改为先要 trust,非交互则退出);mcp_tool 型 hook 在阻塞事件上不再因 MCP 服务器仍在连接而被跳过,改为等待至 MCP 连接超时。另有 v2.1.280 修的后台会话在环境变量含 NUL 字符时无法运行 git / hook / 插件,以及 v2.1.268 前 PermissionRequest hook 在 --print 模式下根本不触发。

结论是:hook 不只是安全边界,也是缓存边界。设计 hook 时,输出什么、按什么顺序输出、以及输出是否跨轮次稳定,都应作为提示前缀的一部分来考量。

超时是 Hook 安全的关键——如果一个 PreToolUse Hook 因为网络请求卡住了 30 秒,整个 Claude Code 不应该被阻塞。超时后 Hook 被强制终止,工具执行按”无 Hook 返回”处理(继续正常权限检查)。

⚠️ 但这个”超时即放行”的结论有一个例外,且是官方明示的:PreModelSwitch 是 fail-closed 的。官方 hooks 文档的原话是 “On PreModelSwitch, a hook canceled at its timeout blocks the model switch”——换言之,在绝大多数事件上,超时意味着”hook 没来得及给决策,于是当作没有决策”;而在 PreModelSwitch 上,超时意味着切换被阻断。同文档的决策表也把该事件标记为 “Yes | Blocks the model switch and shows stderr to the user”。

超时不是一个数,而是一张矩阵。 上面把超时讲成了单一默认值,官方实际给的是按类型与按事件两级下调:

层级默认超时
按 hook 类型command / http / mcp_tool 600 秒;prompt 30 秒;agent 60 秒
按事件下调(command/http/mcp_tool)UserPromptSubmit / PreModelSwitch / PostModelSwitch 降到 30 秒;MessageDisplay 降到 10 秒
SessionEnd共享 1.5 秒预算(不是每个 hook 各 1.5 秒)

SessionEnd 那一行是整张表里最反直觉的:它比任何一类默认值都小一到两个数量级,而且是多个 hook 合起来只有 1.5 秒。这直接解释了一类常见困惑——“我的 SessionEnd hook 为什么总是不执行完”。它不是没跑,是被 1.5 秒的共享预算掐断了。抬预算有两条路:给单个 hook 设 timeout(整体预算会自动抬到与之匹配,上限 60 秒,但插件提供的 hook 上的 timeout 不会抬预算),或设 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS。⚠️ 后一条在 v2.1.268 之前只能抬整体预算,没有自己 timeout 的 hook 仍会在 1.5 秒后被取消——设了环境变量却没生效,正是这个版本差。

失效方向也会翻转:托管 hook 白名单从 fail-open 改为 fail-closed。 上面讨论的是单个 hook 的超时,治理侧另有一条方向性修正更值得记住:v2.1.267 起,托管设置 allowedHttpHookUrls、httpHookAllowedEnvVars、allowedChannelPlugins 在不可读时,从此前的”放行一切”改为”不放行任何东西”。这是白名单失效方向的一次翻转——读不到配置时,旧行为等于把整套 hook 管控静默关掉。对安全论证是正面材料,但运维上的后果是:配置文件损坏会直接让所有 HTTP hook 停摆,而不是像过去那样”照常工作”。

实践含义很直接:写一个会做网络调用的 PreModelSwitch hook 是危险的。网络抖动不会让切换”照常进行”,而会让切换失败。这与本章其余部分的直觉完全相反——读者若照搬”超时放行”的通用理解去写这个 hook,会得到”模型永远切不了”而不是”切换照常”。建议给足 timeout,并显式设计超时兜底路径(例如超时时默认输出 allow),而不是依赖系统的默认放行。

这也是为什么 PreModelSwitch 在官方生命周期图里被标为 standalone sequential event——它是顺序执行的,Claude Code 会等它返回再继续。相对地,PostModelSwitch 是 async 事件,且因为模型已经变了,它无法阻断任何事。

源码引用:Hook 系统实现位于 src/utils/hooks.ts,包含 Hook 执行、匹配逻辑和超时控制。

11.6 实际应用场景

几个真实的 Hook 使用场景:

1. 代码格式化前置检查:

{
  "matcher": "FileWrite",
  "command": "prettier --check ${file} || echo 'deny'"
}

在文件写入前运行 Prettier 检查,格式不合规则拒绝写入。

2. Git 操作审计:

{
  "matcher": "Bash",
  "command": "echo \"$(date) Bash: ${command}\" >> ~/.claude-audit.log"
}

记录所有 Bash 命令到审计日志。

3. Slack 通知集成:

{
  "matcher": "",
  "command": "curl -X POST $SLACK_WEBHOOK -d '{\"text\": \"${title}: ${body}\"}'"
}

把 Claude Code 的通知转发到 Slack 频道。

11.7 设计启示

Hooks 是 Claude Code 扩展性的关键支柱。它的设计遵循了一个原则:开放扩展点,而不是开放内部实现。用户不需要修改 Claude Code 的代码,只需要在约定的时机点插入自己的脚本。

这种设计在企业场景中尤其重要——IT 部门可以通过 Hooks 实施安全策略和审计要求,而不需要 fork Claude Code 的代码库。