24 讲路线 · 与 S10 配对
D10: Hooks Extension 深挖 · Hooks 扩展
本讲在 S10 主线之上,聚焦实现细节、边界条件与自测;导图与主线相同模块,便于对照。
建议:先读完 S10,再按下方顺序走读源码与练习。
🔬 深挖目标
Hooks 把固定流程变成可插拔管道。本讲要求你能对照真实调度代码回答:事件从哪进、匹配规则、并行还是串行、权限/改参如何合并、超时与信任门、失败是否阻断主流程——而不是只背事件名。
📂 源码锚点(本仓库 ccsource/claude-code-main)
克隆课程仓库且已包含 ccsource 时,按下表打开文件即可;行号随上游变动可能漂移,以符号名搜索为准。
| 路径 | 读什么 |
|---|---|
src/utils/hooks.ts | executeHooks、executePreToolHooks / executePostToolHooks、TOOL_HOOK_EXECUTION_TIMEOUT_MS、信任与 CLAUDE_CODE_SIMPLE 早退。 |
src/services/tools/toolHooks.ts | runPreToolUseHooks / runPostToolUseHooks:把 hook 生成器产物映射成 hookPermissionResult、hookUpdatedInput、stop 等。 |
src/services/tools/toolExecution.ts | checkPermissionsAndCallTool 里消费 pre-hook 的 for await 循环:每次 hookUpdatedInput 都会覆盖 processedInput。 |
src/utils/hooks/execAgentHook.ts 等 | 各 type(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 本质是子进程,别把秘密写进日志。 |
📖 走读顺序(带搜索关键词)
hooks.ts:搜export async function* executePreToolHooks,看hookInput结构与hasHookForEvent短路。- 同文件搜
async function* executeHooks,读getMatchingHooks→ 并行hookPromises→permissionBehavior优先级与updatedInputyield。 toolHooks.ts:runPreToolUseHooks分支表,对照 S10 配置 JSON 与真实AggregatedHookResult字段。toolExecution.ts:case 'hookUpdatedInput',确认改参如何进入后续权限与工具执行。- 本地跑一条真实
PreToolUsecommand(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 引入。实现上需区分:工具真实返回值、进入对话上下文的值、用户可见附件是否三层一致——任何不一致都应在产品说明里写清。