第 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-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 的两个特殊规则:

  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任务完成时进度追踪

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指令加载时指令验证
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 由谁跑(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 作为返回外部工具集成、脚本自动化
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直接拒绝,不执行工具外部审计系统一票否决
无返回 / 其他继续正常的权限检查流程仅做日志记录

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.” 三个后果必须记住:

  1. 未知键被丢弃——你塞进去的多余字段不会报错,而是静默消失;
  2. 工具拒绝的入参直接导致调用被 deny——改写不再是”一定成功”的操作,它可能把一次本来能跑的调用变成拒绝;
  3. 这意味着 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支持 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 失效
并发上限每会话(含子代理)最多 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”。三个语义变化分点写准:

  1. 计数单位从「事件」变成「会话」——上限跨事件共享,且子代理的 hook 也计入这张账;
  2. 数量 12 → 24;
  3. 新增豁免队列——已等待超过其事件 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.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.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 / PermissionRequest hook,在 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 的代码库。