首页 / 课程 / D10 深挖
Part 3: 扩展集成 · 第 20 讲

24 讲路线 · 与 S10 配对

D10: Hooks Extension 深挖 · Hooks 扩展

本讲在 S10 主线之上,聚焦实现细节、边界条件与自测;导图与主线相同模块,便于对照。

建议:先读完 S10,再按下方顺序走读源码与练习。

模块导图(与 S10 同源,便于对照):生命周期、沙箱与幂等

🔬 深挖目标

Hooks 把固定流程变成可插拔管道。本讲要求你能对照真实调度代码回答:事件从哪进、匹配规则、并行还是串行、权限/改参如何合并、超时与信任门、失败是否阻断主流程——而不是只背事件名。

📂 源码锚点(本仓库 ccsource/claude-code-main

克隆课程仓库且已包含 ccsource 时,按下表打开文件即可;行号随上游变动可能漂移,以符号名搜索为准。

路径读什么
src/utils/hooks.tsexecuteHooksexecutePreToolHooks / executePostToolHooksTOOL_HOOK_EXECUTION_TIMEOUT_MS、信任与 CLAUDE_CODE_SIMPLE 早退。
src/services/tools/toolHooks.tsrunPreToolUseHooks / runPostToolUseHooks:把 hook 生成器产物映射成 hookPermissionResulthookUpdatedInputstop 等。
src/services/tools/toolExecution.tscheckPermissionsAndCallTool 里消费 pre-hook 的 for await 循环:每次 hookUpdatedInput 都会覆盖 processedInput
src/utils/hooks/execAgentHook.tstype(command / http / prompt / callback)如何落盘为子进程或 RPC。

另有最小可读实现(教学用,非上游快照):course/examples/s10-hooks.ts,适合对照类型与注册表思路。

🪝 生命周期矩阵

  • PreToolUse:可在进入 canUseTool 前改写 processedInput,或通过 permissionBehavior 走 allow / ask / deny;也可 blockingError 直接否决。
  • PostToolUse:拿到真实 tool_response 后做审计、脱敏、替换 MCP 输出(见源码 updatedMCPToolOutput);不能替代「尚未发生的批准」。
  • 匹配executePreToolHooks 传入 matchQuery: toolName,配置侧按工具名过滤;具体合并顺序见下一节。

⚡ 并行、权限合并与「最后一条改参获胜」

与「hook 按注册顺序串行改同一个字段」的直觉不同,上游对一批匹配的 command/prompt/… hook采用并行启动 + 聚合结果matchingHooks.map(…) 后为 for await (const result of all(hookPromises))。权限类结果有明确优先级:deny > ask > allow(见注释 Apply precedence rules)。

若多个 hook 仅返回 updatedInput(无 permissionBehavior),引擎会逐条 yield;在 toolExecution.ts 中每收到一条 hookUpdatedInput 就执行 processedInput = result.updatedInput——因此多 hook 改同一键时,最终生效值取决于异步完成顺序在生成器中的产出次序,一般应视为非确定性,产品侧应禁止多 hook 争用同一字段,或合并为单一 hook。

📎 源码摘录(ccsource/.../hooks.ts

下列片段与当前课程随附的 ccsource/claude-code-main 一致,便于你全文搜索时核对;上游更新后请以本地文件为准。

// L2224 起:一批匹配到的 hook 并行执行(非 for 循环串行 await)
// Run all hooks in parallel with individual timeouts
const hookPromises = matchingHooks.map(async function* (...) { ... })

// L2903 起:聚合多条并行结果时的权限优先级
// Check for permission behavior with precedence: deny > ask > allow
switch (result.permissionBehavior) {
  case 'deny':
    permissionBehavior = 'deny' // deny always takes precedence
    break
  case 'ask':
    if (permissionBehavior !== 'deny') permissionBehavior = 'ask'
    break
  case 'allow':
    if (!permissionBehavior) permissionBehavior = 'allow'
    break
}
// toolExecution.ts(checkPermissionsAndCallTool)— hook 改参覆盖
case 'hookUpdatedInput':
  processedInput = result.updatedInput

🧪 超时、信任门与环境开关

主题源码/行为要点
默认超时TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000(10 分钟)为常见默认值;单 hook 可自带 timeout
工作区信任executeHooks 开头:未接受信任时直接跳过用户 hook,集中防止交互模式下的 RCE 面。
CLAUDE_CODE_SIMPLE为真时整段 executeHooks 早退,等价于关闭复杂 hook 管线(调试/极简路径)。
Hook 再调模型仍可能递归或拖死会话;应用层需配额、深度与「hook 内禁止再触发同类事件」的约定。
Hook 写磁盘继承宿主权限与 D03;命令类 hook 本质是子进程,别把秘密写进日志。

📖 走读顺序(带搜索关键词)

  1. hooks.ts:搜 export async function* executePreToolHooks,看 hookInput 结构与 hasHookForEvent 短路。
  2. 同文件搜 async function* executeHooks,读 getMatchingHooks → 并行 hookPromisespermissionBehavior 优先级与 updatedInput yield。
  3. toolHooks.tsrunPreToolUseHooks 分支表,对照 S10 配置 JSON 与真实 AggregatedHookResult 字段。
  4. toolExecution.tscase 'hookUpdatedInput',确认改参如何进入后续权限与工具执行。
  5. 本地跑一条真实 PreToolUse command(echo JSON),用日志验证只触发一次 Pre、一次 Post。

✏️ 自测 1 · 参考答案:两个 hook 改同一参数,谁说了算?

题干

配置了两个 PreToolUse hook,都对 Bash 生效,且都在 JSON 里返回 hookSpecificOutput.updatedInput 修改同一字段(例如 command)。最终进入工具执行的到底是哪一份?用户问「优先级」时你怎么解释?

结论

这批 hook 在引擎侧并行执行;聚合时每条 hook 的 updatedInput分别 yield。宿主在 checkPermissionsAndCallTool 中对 hookUpdatedInput 的处理是简单覆盖——后收到的那条覆盖先前的 processedInput。由于并行完成顺序不稳定,不应依赖「注册顺序」或「文件顺序」作为契约

向用户怎么说

  • 文档写死:不要配置多个会改写同一键的 Pre hook;需要多段逻辑请合并为一个脚本或在脚本内自行合并。
  • 若必须链式改写,应在单一 hook内顺序处理,或改上游引入显式全序(当前开源实现未保证)。

代码锚点(覆盖语义,与仓库 ccsource 对齐):

// toolExecution.ts — 每来一条 hookUpdatedInput 就整体替换 processedInput
case 'hookUpdatedInput':
  processedInput = result.updatedInput

✏️ 自测 2 · 参考答案:Post-hook 能「自动 approve」吗?

题干

能否靠 PostToolUse hook 实现「某类工具一律自动批准」?安全代价是什么?

结论

不能把 Post 当成批准门:批准发生在工具尚未执行之前,对应的是 PreToolUse 返回的 permissionBehavior: 'allow'(以及正常权限流里的 canUseTool)。PostToolUse 运行时工具已经完成,最多做日志、脱敏、改写展示给模型的 MCP 输出等。

若强行「看起来像自动批准」

只能用 PreToolUse 或策略引擎在 canUseTool 前放行。代价是:任何误匹配规则都会真实执行危险工具(磁盘、网络、子进程),且审计必须记录「由 hook 策略自动 allow」以供追责。

✏️ 自测 3 · 参考答案:并行 hook 的 allow / ask / deny 谁赢?

题干

同一 PreToolUse、同一工具,hook A 返回 allow,hook B 返回 ask,hook C 返回 deny。最终权限行为是什么?

结论

executeHooks 的结果聚合里,注释写明优先级:deny > ask > allow。因此最终为 deny。这与「最后一个改参获胜」是两套逻辑:权限走聚合优先级,纯 updatedInput 走多次覆盖。

教学提示:设计策略时,deny 应视为安全绳——任一子系统否决即不执行。

✏️ 自测 4 · 参考答案:Post 改写输出与审计链

题干

PostToolUse hook 若替换 MCP 工具返回体(源码中的 updatedMCPToolOutput),审计日志应记录「原始结果」还是「改写后结果」?模型看到的与落盘的一致吗?

结论

合规上应至少保留原始结果或哈希在不可篡改存储中,改写给模型的内容单独记为「hook 变换后」。若只记改写版,事后无法复盘数据泄露是否由 hook 引入。实现上需区分:工具真实返回值进入对话上下文的值用户可见附件是否三层一致——任何不一致都应在产品说明里写清。

✏️ 自测(题干回顾)

  • 两 hook 改同一参数谁优先?→ 自测 1
  • Post 能否自动 approve?→ 自测 2
  • 并行 allow/ask/deny 谁赢?→ 自测 3
  • Post 改写输出如何审计?→ 自测 4