章节导航 第 11 章 / 共 23 章
Hooks 与生命周期
PreToolUse / PostToolUse / Notification 钩子系统、事件驱动的扩展模型、用户自定义脚本的安全执行机制
11.1 为什么需要 Hooks
工具系统(第 5 章)定义了 Claude Code 能做什么,权限系统(第 7 章)定义了它被允许做什么。但用户可能有更细粒度的需求:
- 在每次文件写入之前运行 lint 检查
- 在 Bash 命令执行之后记录审计日志
- 在特定工具调用时触发自定义通知
- 在 Claude Code 发送通知时执行额外操作
Hooks 系统提供了这种扩展能力——在工具执行的生命周期中插入用户自定义的脚本。
11.2 Hook 事件全景
官方 Hooks 参考文档的 “Hook lifecycle” 表当前列出 33 个事件(快照日期 2026-11-04)。此前本章写的是”28+ 种”——这个表述的问题在于无法对账:读者既不知道确切数字,也无法判断清单是否落后。下面按本书的分组方式逐一列出,并在最后给出对账。
一次方法论的正向验证。 快照日期从 2026-09-10 更新到 2026-10-19,中间跨了 32 个 release(v2.1.269 至 v2.1.303;起点版本 v2.1.268 的发布日正是 2026-09-10),事件数仍是 33,一个没增、一个没减。这说明”记快照日期 + 给出读者可重做的加法”这套维护方式,比追着每个版本改数字更耐用:数字变了,读者能自己重算;数字没变,这个”没变”本身也是可陈述的事实。反之,如果本章只写”33 种”,读者既无法验证,也无法知道它是什么时候的 33 种。
第二次验证(2026-11-06 复核)。 快照日期从 2026-10-19 再推进到 2026-11-04(v2.1.318),窗口 v2.1.304~318 又跨 13 个 release,事件数仍是 33。窗口内 changelog 里 hook 相关的新增均不进事件表——CLAUDE_RUNNER_SPAWN_REASON 是 runner 侧环境变量、spinner 状态显示是 UI 层——「什么算一个事件」的口径在实践中被再次确认:只有进入官方 Hook lifecycle 表的才算,旁路的环境变量与 UI 状态不算。
本轮复核另发现官方措辞有两处细化,均与本表格咬合:PermissionDenied 现写明覆盖”including denials without a classifier verdict”(无分类器判定的拒绝也算);PostModelSwitch 写明覆盖”including changes Claude Code makes on its own, such as restoring the model when you resume a session”(Claude Code 自己造成的切换也算)——后一句正是下面”模型切换类”小节所引官方排除条款的正面表述,可作为该段的一手佐证。
需要注意: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 的两个特殊规则:
- 切换类事件收到的是
from_model/to_model,而不是单个model字段——官方明确只有SessionStart才会收到model。原因很直白:一次迁移无法用单个 model 字段描述。另注意没有$CLAUDE_MODEL环境变量;$ANTHROPIC_MODEL可以读但不会随切换改变,不能用它判断当前模型。 - 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.*)。 - 当 Claude Code 无法确定目标规范名时(例如只有你的 LLM gateway 认识的自定义模型 ID),它会无视 matcher、运行所有 PreModelSwitch hook。因此一个要阻断的 hook 必须自行检查输入里的
to_model,不能只依赖 matcher。
权限类
| Hook 事件 | 执行时机 | 典型用途 |
|---|---|---|
| PermissionDenied | 权限被拒绝时 | 审计告警 |
| PermissionRequest | 权限请求时 | 动态授权策略 |
Agent 协作类
| Hook 事件 | 执行时机 | 典型用途 |
|---|---|---|
| SubagentStart | 子代理启动时 | 资源分配监控 |
| SubagentStop | 子代理停止时 | 结果收集、状态同步 |
| TeammateIdle | 队友空闲时 | 任务再分配 |
| TaskCreated | 任务创建时 | 依赖检查 |
| TaskCompleted | 任务完成时 | 进度追踪 |
SubagentStop 的执行时机一行要带一个修复注:它曾按时间轮询而非事件触发——v2.1.311 修复 “Fixed SubagentStop hooks running about twice a minute for each running subagent instead of once when it finishes”,即此前对每个运行中的子代理每分钟约跑两次,而不是完成时跑一次。对把它当审计点用的读者影响直接:审计日志里曾出现大量重复记录。触发语义是「事件」不是「轮询」——这条与 11.4「hook 输出写错时会怎样」是姊妹条:那次是输出错,这次是触发时机错。
上下文管理类
| Hook 事件 | 执行时机 | 典型用途 |
|---|---|---|
| PreCompact | 上下文压缩前 | 保留关键信息 |
| PostCompact | 上下文压缩后 | 验证信息完整性 |
| CwdChanged | 工作目录变更时 | 项目上下文切换 |
| FileChanged | 文件变更时 | 自动格式化、测试触发 |
| ConfigChange | 配置变更时 | 策略热更新 |
| InstructionsLoaded | 指令加载时 | 指令验证 |
| WorktreeCreate | Worktree 创建时 | 环境初始化 |
| WorktreeRemove | Worktree 移除时 | 清理 |
| DirectoryAdded | 会话中通过 /add-dir 新增工作目录后 | 准备新仓库(如安装依赖) |
DirectoryAdded 有三个不触发的边界,值得记住:用 --add-dir 启动参数传入的目录不触发(由 SessionStart 覆盖);在 /permissions 的 Workspace 标签页添加的目录不触发;添加一个已经是或位于现有工作目录之内的目录不触发。它在沙箱与权限状态刷新之后才触发,所以 hook 里的沙箱工具已经能看到新目录。
用户交互类
| Hook 事件 | 执行时机 | 典型用途 |
|---|---|---|
| UserPromptSubmit | 用户提交提示时 | 输入预处理、过滤 |
| UserPromptExpansion | 用户键入的 slash command 展开为 prompt 之前 | 阻止特定命令被直接调用、为某个 skill 注入上下文、记录命令调用 |
| Notification | Claude 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 事件 | 执行时机 | 典型用途 |
|---|---|---|
| MessageDisplay | assistant 文本输出到显示层之前 | 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 async | WorktreeCreate WorktreeRemove Notification ConfigChange InstructionsLoaded CwdChanged FileChanged DirectoryAdded | 异步旁路,适合做观测与通知,不应用来阻塞 |
| standalone sequential | PreModelSwitch | 在模型切换前同步执行,也是唯一一个超时 fail-closed 的事件(见 11.5) |
| display-only | MessageDisplay | 只影响显示文本,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 由谁跑(shell / HTTP / MCP / 模型 / 子代理),async 字段决定它是否阻塞主流程——任何一个类型都可以被放到后台。
| 字段 | 效果 | 生效条件 |
|---|---|---|
async: true | 后台运行,不阻塞主流程;不受 timeout 约束;输出在下一个 turn 才送达 | 官方明示作用于 command hook |
asyncRewake: true | 在 async 基础上,退出码 2 时立即唤醒 Claude,即使会话处于空闲 | 同上 |
async 与 asyncRewake 是两个独立字段,不是同一字段的两种取值(官方文档 common fields 表分列两行)。区别在于送达时机:普通 async hook 的输出要等下一个 turn 才被读到,而 asyncRewake 在失败时把 stderr(stderr 为空则用 stdout)以 system reminder 的形式立刻推给 Claude,让它能对长时间运行的后台失败作出反应。代价是每次执行都是独立的后台进程。
这个维度与 11.2 末尾那张”按运行时形态切分”的表不是同一件事:那张表按事件分(这个事件发生在 agentic loop 内还是 loop 外),而 async 按单个 hook 分。同一个事件下可以既有同步 hook 又有 async hook。11.5 的超时矩阵默认所有 hook 都会被掐断,async hook 是那张矩阵里唯一整行不适用的例外——它根本不进超时。
异步 hook 的失效长什么样,看两条已修的 bug 就够:v2.1.287 之前,asyncRewake hook 的脚本文件缺失时会反复以 “found issues” 唤醒 Claude(改为只报一次);v2.1.295 修了 async hook 的 JSON 输出跨多行打印时被静默忽略。两条都是同一个教训在两侧的体现——异步 hook 的错误不落在你能立刻看见的地方,要么迟到,要么重复,要么没有。
Hook 类型从最初的单一 command 扩展为 5 种,每种类型有不同的执行模型:
| 类型 | 执行方式 | 输出格式 | 适用场景 |
|---|---|---|---|
| command | 执行 shell 命令 | stdout 作为返回 | 外部工具集成、脚本自动化 |
| http | POST 到 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 | 直接拒绝,不执行工具 | 外部审计系统一票否决 |
| 无返回 / 其他 | 继续正常的权限检查流程 | 仅做日志记录 |
allow 的效力按宿主分叉(v2.1.314)。 上表把 allow 写成「跳过后续权限检查,直接允许」——在 Cowork 云端会话的 auto mode 下这不再成立:“Changed auto mode in Cowork cloud sessions: a hook’s allow of an MCP tool call now counts as no answer, so the classifier or a permission prompt decides”——hook 的 allow 算作没有答案,交回分类器或权限提示裁决。allow 不是在所有宿主上都等价于放行,这与 11.5.2「workspace trust 按会话形态分叉」是同一种「同一机制在不同宿主上语义不同」的模式。
Monitor 工具启动的 shell 命令也受 Bash hook 约束,但只能收紧(v2.1.314)。 两条官方原文:hook 的 if 条件如 Monitor(tail *) 现在按 Bash(tail *) 的语义匹配 Monitor 工具的 shell 命令;匹配 Bash 的 hook 也会跑在 Monitor 启动的 shell 命令上——但这类 hook 只能 block 或 ask,不能 approve 或 rewrite。一个只能收紧、不能放宽的拦截面:拦截面覆盖了新的命令来源,授权面没有。
退出码协议。command 类型 Hook 的退出码与权限决策有明确映射:
| 退出码 | 含义 | 权限效果 |
|---|---|---|
| 0 | 成功继续 | 等同于 allow |
| 2 | 阻塞 | 等同于 deny(仅 PreToolUse) |
| 其他非零 | 错误但不阻塞 | 继续正常权限检查 |
11.4.0 失败即阻断:把”hook 没跑起来”也算一种决策
上表有一个默认前提:hook 至少跑起来了。而写错一个脚本路径、脚本崩溃、网络不通,hook 就没跑起来——此时默认行为是 fail-open:“when a hook fails or times out, Claude Code still carries out the action. A policy hook with a wrong path or a crashing script then lets everything through.”
官方把这句话列为 11.5 之外最需要单独讲的一条,因为它正是安全 hook 最怕的失效模式:策略写对了,但策略静默失效。v2.1.295(2026-10-08)起,command 与 http 型 hook 可以声明 "onFailure": "block",把”失败”本身变成一种决策——失败时按该事件上的退出码 2 语义处理(PermissionRequest 上则直接拒绝该请求)。
官方列举的五类 failure:
| 类别 | 判定 |
|---|---|
| 无法启动 | command hook 起不来,例如脚本或可执行文件不存在 |
| 退出码非 0 且非 2 | 即使 stdout 打印了 permissionDecision: "allow" 的 JSON 也算失败;要返回 JSON 决策必须退出 0 |
| HTTP 错误 | 连接失败,或响应状态码不是 2xx |
| 超时 | 达到 timeout |
| 输出非法 | JSON 无法解析或 schema 校验失败;HTTP hook 的 2xx 响应体非空且非 JSON 对象也算 |
command hook 的纯文本 stdout 不算失败——这一格容易写错:不加 onFailure 时它走的是”继续正常权限流程”,加了 onFailure: "block" 之后它仍是失败并阻断。
两个必须记住的边界:
- 有四个事件上
onFailure: "block"无效:Stop、SubagentStop、TaskCompleted、TeammateIdle。官方给的理由很实在——exit code 2 on these events sends Claude back to keep working, and Claude can’t repair a hook that won’t run(让 Claude 回去继续干活,而它修不好一个跑不起来的 hook)。 - async hook 上同样无效(呼应 11.3.1)。
fail-closed 后来覆盖到了第三种声明形态(v2.1.317)。 上文写的 onFailure: "block" 属于配置声明(command / http 型);v2.1.317 补上了 Agent SDK 的回调式 hook:“Added onFailure: 'block' for Agent SDK hook callbacks, so a callback that fails blocks the action instead of letting it run”——SDK 里以回调函数注册的 hook,失败同样阻断动作而非放行。三种形态(command 声明 / http 声明 / SDK 回调)至此都有了显式写死方向的出口。
「输出非法」这一格也有了第一个量化边界:PreCompact hook 的输出截断于 10,000 字符(v2.1.314)——超限不仅截断,此前会使压缩失败或反复执行。这是 hook 输出上限的第一个确切数字,与 11.5 前缀稳定性表跨条呼应(第 9 章已从压缩侧记录同一条)。
方向性要说清楚:这是给用户的可选项,不是默认行为的改变。fail-open 仍是默认;想保留旧行为就显式写 "onFailure": "continue"。对安全策略 hook,本书的建议是显式写死方向,不要依赖默认值——因为默认值会随平台和版本变化,见 11.5 的”平台”一格。
匹配器增强。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 起引入了改写模式——不改变执行权限,但改写模型视角的工具输出。改写是双向的:既有结果侧的 updatedToolOutput,也有入参侧的 updatedInput。只讲前者会让读者以为”改写”是 PostToolUse 独有的能力,而实际上入参侧发生得更早,影响的是工具真正执行什么。
| 模式 | 机制 | 效果 |
|---|---|---|
| 允许 | allow | 跳过权限检查,直接执行 |
| 拒绝 | deny | 阻止执行 |
| 默认 | 无返回 | 继续正常权限检查 |
| 改写 | updatedToolOutput | 执行后替换模型看到的输出 |
updatedToolOutput 字段允许 Hook 将工具真实输出替换后再呈现给模型——适用于所有工具类型,不仅仅是 Bash。Transcript 中保留原始输出,只有模型上下文中被改写。
输入侧:updatedInput。 PreToolUse 可在 hookSpecificOutput 下返回 updatedInput,替换工具将要执行的入参;PermissionRequest 则在 decision 对象内同样支持。
⚠️ v2.1.300(2026-10-14)起,updatedInput 不再是简单的字符串替换。 官方 changelog 原文:“Changed hooks: updatedInput from PreToolUse and PermissionRequest hooks is now validated like Claude’s own input: unknown keys are dropped, and input the tool rejects denies the call.” 三个后果必须记住:
- 未知键被丢弃——你塞进去的多余字段不会报错,而是静默消失;
- 工具拒绝的入参直接导致调用被 deny——改写不再是”一定成功”的操作,它可能把一次本来能跑的调用变成拒绝;
- 这意味着
updatedInput的失败模式是拒绝而不是报错,与 11.4.0 的 fail-open 默认恰好相反。
MCP 侧的对应物:updatedMCPToolOutput。 官方文档另有此字段,但只对 MCP 工具生效,且官方明示 prefer updatedToolOutput。写 MCP hook 时不要因为”这是 MCP 工具”就选它——除非你确实需要只在 MCP 路径上改写。
一句话结论:改写是双向的;入参侧更早、影响的是执行什么,v2.1.300 之后它还要过模型自己的输入 schema。
典型场景:
- 密钥脱敏:
kubectl get secrets输出中扫描并脱敏 AWS/Azure 密钥后,再将脱敏版呈现给模型 - 输出压缩:50KB 的测试输出压缩为一段摘要,减少上下文占用
- 跨平台规范化:将 macOS 风格路径转为 Linux 风格,使模型在跨平台协作中不受路径格式干扰
与 additionalContext 的区别:updatedToolOutput 是替换模型看到的工具输出,而 additionalContext 是追加额外信息——前者控制模型看到什么,后者补充模型还应该知道什么。
风险警告:(1) 脱敏后模型无法看到真实值,可能影响后续推理——需确保脱敏的字段后续不需要;(2) 多 hook 同时返回
updatedToolOutput存在竞态,建议每个 matcher 只注册一个 rewriting hook;(3) 静默错误屏蔽——如果 hook 过滤了 stderr 中的警告信息,模型可能做出错误决策。
11.4.2 退出码 2 的三类例外(Elicitation 只是其中之一)
上面的退出码协议有一个必须先记住的边界。官方 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 不是唯一的例外——本章此前把它写成”唯一”,是因为只对着”退出码 2 后 JSON 还读不读”这一条通则看。官方另有一张 Exit code 2 behavior per event 表,逐事件列出 exit 2 的效果;按那张表对账,例外分三种性质不同的类型,而只有分清性质才知道该怎么改写自己的 hook:
| 类型 | 涉及事件 | 官方口径 | 为什么 |
|---|---|---|---|
| 协议型 | PermissionRequest | ”Exit code 2 isn’t honored for this event and the permission flow proceeds unchanged. Deny through the decision object instead” | 这个事件的决策通道根本不是退出码。写了 exit 2 完全没效果 |
| 时序型 | PostToolUse、PostToolUseFailure、StopFailure、PostModelSwitch | 不阻塞。但四个事件的口径并不相同:PostToolUse / PostToolUseFailure 是 Shows stderr to Claude(工具已跑完 / 已失败);StopFailure 是 Output and exit code are ignored, except terminalSequence;PostModelSwitch 是 Shows stderr to user only(模型已切完) | 被阻断的事已经发生了,时间上来不及 |
| 通道型 | SubagentStart、SessionStart、Setup、SessionEnd、CwdChanged、FileChanged、PostCompact、DirectoryAdded、Notification、InstructionsLoaded、MessageDisplay、PermissionDenied | 不阻塞;stderr 只给用户看,或进 debug log,或完全忽略 | 这些事件没有”阻止什么”的语义 |
协议型才是致命的那一类。 时序型与通道型最多是”你的阻断没生效”,表现是动作照常发生;协议型则是”你写的东西被整个忽略”——PermissionRequest 上写 exit 2,权限流程照原样走,连一条警告都没有。写 hook 的人排查时最容易栽在这里,因为现象与”hook 没被触发”难以区分。
PermissionDenied 的替代通道:retry。 这个事件在通道型清单里,官方给的替代物是 JSON 而非退出码:Use JSON hookSpecificOutput.retry: true to tell the model it may retry——告诉模型被拒绝的工具调用可以再试一次。但紧接着一条限制:Claude Code ignores retry: true for no-verdict denials。
这一条把 auto mode 的拒绝切成了两个不同的可干预面:有分类器判定的拒绝(verdict)上 retry 生效,无判定的拒绝(no-verdict)上被忽略。11.2 把 PermissionDenied 描述为”33 个事件里唯一一个由另一个事件的特定结果触发的事件”——那个描述只覆盖了触发来源,没覆盖它能做什么;补上 retry 之后,auto mode 拒绝这条路径第一次有了”告诉模型还能再试”的能力。
可阻塞的完整清单是 16 个事件(此前按提案写作 12 个,系漏计 WorktreeCreate 与 WorktreeRemove;本轮按官方逐事件表逐条数得):PreToolUse、UserPromptSubmit、UserPromptExpansion、Stop、SubagentStop、TeammateIdle、TaskCreated、TaskCompleted、ConfigChange、PostToolBatch、PreCompact、PreModelSwitch、Elicitation、ElicitationResult、WorktreeCreate、WorktreeRemove。其中后两者的措辞也值得照抄——它们不是”exit 2 阻塞”,而是 any non-zero exit code 就导致 worktree 创建/移除失败,即它们的失败语义比其他事件更严一档。
回到 Elicitation 与 ElicitationResult:“On Elicitation and ElicitationResult, an exit-2 hook’s hookSpecificOutput is ignored.” 读者若按通则给这两个事件写退出码 2 的 hook,会以为 content 里的表单字段还能被读到——读不到。它们属于可阻塞清单(与上表三类例外不冲突:可阻塞说的是 exit 2 能否拦下动作,hookSpecificOutput 被忽略说的是拦下之后的 JSON 还读不读)。
两个事件的语义与 block 行为:
| 事件 | 触发时机 | matcher | 支持 block | block 的效果 |
|---|---|---|---|---|
Elicitation | MCP 服务器在工具调用中请求用户输入时 | 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 失效 |
| 并发上限 | 每会话(含子代理)最多 24 个 prompt / agent 型 hook 同时运行;另有最多 12 个豁免位给已等待超过其事件 timeout 的 hook;timeout 低于 1 秒按 1 秒计(v2.1.312 起) | 防止不可预测耗时的 hook 把单轮延迟乘以 hook 数量 |
并发上限的口径已被 v2.1.312(2026-10-27)整体改写,注意别沿用旧表述。 本章初稿写的是「同一事件的 prompt / agent 型 hook 最多 12 个并发,其余排队(v2.1.302 起)」;v2.1.312 的官方原文是 “Changed prompt and agent hooks to run at most 24 at a time per session, sub-agents included, plus at most 12 that have waited longer than their event’s timeout; a timeout under 1 second now counts as 1 second”。三个语义变化分点写准:
- 计数单位从「事件」变成「会话」——上限跨事件共享,且子代理的 hook 也计入这张账;
- 数量 12 → 24;
- 新增豁免队列——已等待超过其事件 timeout 的 hook 最多再放 12 个进来跑(不让长队饿死),且
timeout声明低于 1 秒按 1 秒计。
「同一事件 12 个」是 v2.1.302~311 的历史口径;跨事件共享之后,hook 数量预算第一次成为会话级资源,与第 8 章的 session 级安全上限(WebSearch 预算、spawn 配额)属同一家族。
上表前五行约束的是单个 hook 能做什么;新增的第六行约束的是同一时刻能有几个 hook 在跑。性质不同,失效表现也不同:前五行越界会得到一条错误,第六行越界只会变慢,而且慢得没有上限提示——这正是它值得单独列一行的原因。
这一行对 prompt / agent 两种类型的意义远大于其他三种。command hook 的耗时大致可估,prompt 与 agent 型要把判断交给模型甚至起一个子代理,耗时本身不可预测;在没有并发上限时,一个事件挂 20 个 prompt 型 hook 意味着最坏情况下单轮延迟是单个 hook 的 20 倍。⚠️ 官方 changelog 的措辞明确只提 prompt and agent hooks,command / http 型是否有另设的上限未回源,不得外推。
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.265 | agent 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.274 | Stop 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_completeOpenTelemetry 事件新增输出体积与超限输出被存入文件的数量。这补上了 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 前PermissionRequesthook 在--print模式下根本不触发。
结论是:hook 不只是安全边界,也是缓存边界。设计 hook 时,输出什么、按什么顺序输出、以及输出是否跨轮次稳定,都应作为提示前缀的一部分来考量。
超时是 Hook 安全的关键——如果一个 PreToolUse Hook 因为网络请求卡住了 30 秒,整个 Claude Code 不应该被阻塞。超时后 Hook 被强制终止,工具执行按”无 Hook 返回”处理(继续正常权限检查)。
⚠️ 但这个”超时即放行”的结论有一个例外,且是官方明示的:
PreModelSwitch是 fail-closed 的。官方 hooks 文档的原话是 “OnPreModelSwitch, 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 停摆,而不是像过去那样”照常工作”。
实践含义很直接:写一个会做网络调用的
PreModelSwitchhook 是危险的。网络抖动不会让切换”照常进行”,而会让切换失败。这与本章其余部分的直觉完全相反——读者若照搬”超时放行”的通用理解去写这个 hook,会得到”模型永远切不了”而不是”切换照常”。建议给足timeout,并显式设计超时兜底路径(例如超时时默认输出 allow),而不是依赖系统的默认放行。这也是为什么
PreModelSwitch在官方生命周期图里被标为 standalone sequential event——它是顺序执行的,Claude Code 会等它返回再继续。相对地,PostModelSwitch是 async 事件,且因为模型已经变了,它无法阻断任何事。
源码引用:Hook 系统实现位于 src/utils/hooks.ts,包含 Hook 执行、匹配逻辑和超时控制。
11.5.1 超时矩阵的第三个维度:平台
上面那张矩阵按类型和事件两维切分,还缺第三维——平台。未设 shell 的 shell-form hook 在没有 Git for Windows 的 Windows 上会改用 PowerShell 执行,为 Bash 写的脚本可能根本跑不起来;此时 PreToolUse / PermissionRequest 上任何非 0 非 2 的退出码都算失败并阻断调用(v2.1.299 修复的正是此前 fail-open 的行为)。
但「v2.1.299 修复」不是终点——fail-closed 是分四步收敛到的方向,不是一次到位的现状。 后续三步都属于「显式写了 PowerShell / 引号字符串,仍然 fail-open」的残余面:
- v2.1.309:显式声明
"shell": "powershell"的PreToolUse/PermissionRequesthook,在 Git for Windows 未安装时仍 fail-open;且 PowerShell hook 以退出码 2 退出时曾不阻塞。 - v2.1.310:Git Bash 缺失、且 hook 命令是纯引号字符串(如一个带引号的路径)时,
PreToolUse/PermissionRequest仍放行工具调用。 - v2.1.316:PowerShell hook 的命令以 type cast、
param、using或 attribute 开头时,退出码 2 又不阻塞了。
结论升级为:fail-closed 是方向,不是一个版本完成的现状——每一步修的都是一种新的「看起来已经显式声明了,却仍在放行」的残留路径。这反而加强了 11.5.1 的实践建议:跨平台 hook 必须显式写死方向,因为你无法知道自己踩的是不是已被修复的那一步。
这一格必须与 11.4.0 对照着读:同一个 hook,在 macOS 上失败是放行,在没有 Git Bash 的 Windows 上失败是阻断。 也就是说”失败会怎样”这个问题的答案,由三样东西共同决定——onFailure 字段、事件、平台。本章此前把”超时即放行”讲成通则、只给 PreModelSwitch 一个 fail-closed 例外,平台这一维完全没有进入讨论。
官方给的三条出路分别是:安装 Git for Windows;改用 exec form(command + args,不经过 shell)并加 onFailure: "block";或显式写 "shell": "powershell"。想保留 fail-open 行为则显式写 "onFailure": "continue"。三条路的共同点是同一个词:显式。默认值在跨平台时不可依赖。
11.5.2 Workspace trust:来自你不信任的目录的 hook 会不会跑
11.5 表格里”配置源约束:只从 settings.json 读取”这一行,回答的是 hook 从哪来。它没回答另一个问题——来自一个你从未审查过的目录的 hook,会不会执行。官方的答案是分叉的,且分叉线正是”这是不是一个交互会话”:
| 会话形态 | workspace trust 行为 |
|---|---|
| 交互式会话 | Claude Code 扣住所有设置文件里的 hook,包括用户自己的 ~/.claude/settings.json,直到你接受该文件夹(或其信任可延伸到的父目录)的 workspace trust 对话框 |
-p 或 SDK 会话 | 从不显示对话框,并把文件夹当作已信任,因此仓库里 .claude/settings.json 声明的 hook 会在一个你从未信任过的文件夹里直接执行 |
⚠️ 第二行是这一小节里唯一需要读者记住的东西,因为 CI 与自动化正是最常见的非交互形态。官方给的三条缓解是:先审查仓库的 .claude/ 设置文件、用 --bare 启动、或用 --settings '{"disableAllHooks": true}' 对该次运行关掉 hook。
一句话结论:“hook 只来自 settings.json”这句话对你的安全含义,取决于这是不是一个交互会话。
11.5.3 hook 配置本身就是攻击面
本章到此为止讲的全是”系统给 hook 设了什么限制”(超时、隔离、输出、配置源、前缀稳定性),11.7 讲”Hooks 是开放扩展点而不是开放内部实现”。没有一段讲 hook 配置本身是攻击面。2026 年 4—5 月的一起真实事件(Mini Shai-Hulud / TeamPCP 蠕虫)补上了这一格。
一个 npm/PyPI 蠕虫在窃取凭证之后,把一个 SessionStart hook 写进受害者仓库的 .claude/settings.json(执行 node .vscode/setup.mjs),并在 .vscode/tasks.json 里配一个对应的 folderOpen 任务。效果是:删除恶意包、轮换全部凭证之后,开发者下次在这个仓库里开 Claude Code 会话时,恶意代码仍然会执行。 Cloud Security Alliance 称其为首个有文档记录的、用 AI 编程 Agent 配置文件做持久化的供应链攻击。
两个放大这一效果的细节值得单列:.claude/ 与 .vscode/ 常被写进 .gitignore,因此被改动的 hook 不会出现在 PR diff 里——代码评审这道常规防线对它是透明的;攻击者还设了”撤销我的 npm token 就 rm -rf ~/”的死手开关。
⚠️ 数字不写入。 不同来源对规模的描述不一致——CSA 一方称 170+ 个 npm/PyPI 包、累计约 5.18 亿次下载;Microsoft 安全团队一方的分析称 400+ 个包。两者无法调和,本书只写机制与”首个有文档记录”这一定性判断,不给包数与下载量。
这一条把抽象的”配置源约束”变成了一个具体的、已在野发生的失败模式,也是官方文档那句 Command hooks execute shell commands with your full user permissions 的第一个真实注脚。可操作的对仗在两侧:个人侧是 11.5.2 的三条缓解;团队侧则是第 13 章的 allowManagedHooksOnly 与 allowedHttpHookUrls——把 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 的代码库。