# Step 36: Hooks 生命周期
一句话导读:在工具执行的【前】【后】各留一个可编程缝隙——PreToolUse 能拦截或改写入参,PostToolUse 能观察或改写结果。这是让「安全约束、审计、自动修正」这类横切逻辑挂得上去、又不用改工具本身的扩展点。
# 一、这一步做了什么(What)
新增一套生命周期钩子系统(hooks.ts + hooks-builtin.ts),在引擎调工具的两个时机插入回调:
- PreToolUse(执行前):可以
deny(拦截)或modifyInput(改写入参); - PostToolUse(执行后):可以改写结果文本(脱敏、截断、注释)。
配三个示例钩子演示三种能力:
| 钩子 | 时机 | 作用 | 演示的能力 |
|---|---|---|---|
block-dangerous-bash | Pre | 命中 rm -rf /、fork 炸弹等黑名单就拦截 | deny |
ls-add-la | Pre | 把裸 ls 自动改写成 ls -la | modifyInput |
audit-log | Post | 每次工具调用追加一行到 log/hooks.log | observe |
实测:rm -rf / 被拦;ls 被改写成 ls -la;node -v 放行且写进审计日志。
# 二、面试官视角:为什么要做?(Why)
面试题:工具执行前想加安全检查,直接在每个工具的
execute开头写几行不就行了?为什么要单独造一套 Hooks 系统?
因为安全检查、审计、自动修正这类逻辑是横切关注点——它们不属于任何单个工具,却要作用于所有工具。把它们塞进每个工具的 execute,会有三个致命问题:
- 散落且易漏:新加一个工具,很容易忘了补上危险命令检查。约束应该是默认全覆盖,而不是靠每个工具自觉。
- 耦合职责:
Bash工具本该只管跑命令,不该同时操心「审计日志格式」「脱敏规则」。这些逻辑混进去,工具就不纯粹了。 - 不可配置:真实 Claude Code 的钩子是用户在
settings.json里配的外部 shell 命令——用户能自定义规则而不改源码。硬编码在工具里就没了这份灵活。
Hooks 的本质是:在数据流的固定位置开一个「缝」,让第三方逻辑挂进来。工具照旧只管自己的事,横切逻辑集中在钩子链里,且能被外部配置。这是 AOP(面向切面)思想在 Agent 里的落地。
真实版钩子是
settings.json配的外部 shell 命令,靠退出码/stdout 决定放行;我们用进程内函数演示同一套机制——真实的「跑 shell」只是把这里的函数体换成spawn子进程,控制流一模一样。
# 三、原理:它是怎么工作的(How)
# 钩子的返回值就是它的「指令」
Pre 钩子靠返回值表达三种意图,引擎据此决策:
export type PreToolResult =
| void // 不返回 → 放行
| { deny: string } // 拦截,reason 作为"工具结果"反馈给模型
| { modifyInput: any }; // 用新入参替换后继续
2
3
4
HookRegistry.runPre 把钩子链顺序跑一遍,归一成一个 PreOutcome:
async runPre(e: PreToolUseEvent): Promise<PreOutcome> {
let input = e.input;
for (const h of this.pre) {
const r = await h.fn({ tool: e.tool, input });
if (r && "deny" in r) // 任一 deny 立即中止
return { allowed: false, input, reason: r.deny, firedBy: h.name };
if (r && "modifyInput" in r) // modify 累积叠加,喂给下一个钩子
input = r.modifyInput;
}
return { allowed: true, input };
}
2
3
4
5
6
7
8
9
10
11
三条钩子链规则:顺序执行、任一 deny 立即短路、modify 累积(前一个钩子改过的入参传给后一个)。
# 引擎里的接入点
引擎在权限检查之后、真正 execute 之前调 runPre:
const pre = await hooks.runPre({ tool: tc.name, input: tc.input });
if (!pre.allowed) {
yield { type: "hook", tool: tc.name, phase: "PreToolUse",
message: "拦截:" + pre.reason + "(" + pre.firedBy + ")" };
resultBlocks.push(toolResultBlock(tc.id, "blocked by hook: " + pre.reason, true));
continue; // 不执行,把"被拦"作为 tool_result 喂回模型
}
if (pre.input !== tc.input) { /* 用改写后的 input 去执行 */ }
2
3
4
5
6
7
8
执行完再跑 Post 改写结果文本。
# 数据流
AI 决定调用工具
↓
权限检查 canUseTool
↓
★ PreToolUse 钩子链(顺序)→ deny?→ 生成 "blocked by hook" tool_result 喂回模型
│ → modify?→ 用新入参
↓
执行工具 tool.execute(input) ← 这一步才允许并行(Promise.all)
↓
★ PostToolUse 钩子链(顺序)→ 改写结果文本
↓
结果入历史,进入下一轮
2
3
4
5
6
7
8
9
10
11
12
# 为什么 Pre/Post 顺序跑、只有 execute 并行
引擎的 run() 是 async generator,钩子要 yield 出 hook 事件让 UI 看到「被拦/被改写」。generator 的 yield 不能塞进 Promise.all。所以策略是:先顺序跑 Pre 定下 toRun 名单,再并行 execute,最后逐个跑 Post。可观测性(能 yield)和并发性在这里做了明确取舍。
# 四、深入追问(面试常见 follow-up)
Q:被钩子拦下的工具,引擎怎么处理?直接静默丢弃吗?
A:不能静默。被拦时会生成一个 blocked by hook: <reason> 的 tool_result 喂回模型(isError: true)。模型看到「这条路被挡了、原因是 X」,才能换个做法,而不是傻等或以为成功了。拦截必须有反馈,否则模型会在同一堵墙上反复撞。
Q:多个 Pre 钩子的执行顺序和短路语义是怎样的?
A:按注册顺序顺序执行。任一钩子返回 deny 立即短路——后面的钩子不再跑,因为已经决定拦了。modifyInput 则累积叠加:第一个钩子改过的入参会作为第二个钩子的输入,形成一条改写流水线。
Q:为什么 Pre 钩子放在权限检查(canUseTool)之后,而不是之前?
A:权限是「用户/系统层面允不允许用这个工具」,钩子是「用户配置的自定义规则」。先过权限这道系统闸,再过钩子这道可编程闸,分层清晰。而且危险命令拦截(block-dangerous-bash)刻意设计成无论权限是否放行都拦——它是最后一道保险,不该被权限的「放行」绕过。
Q:钩子系统和 step32 的事件流是什么关系?
A:钩子复用了事件流。拦截/改写通过新增的 hook 事件广播给 UI,不需要给钩子系统单开一条回调通道。这体现了 step32 「统一事件流」的价值:新能力想让 UI 看见,只要多 yield 一种事件即可。
# 五、踩坑 / 设计权衡
- 进程内函数 vs 外部 shell:教学用进程内函数,直观、无子进程开销;真实版用外部 shell 命令,换来「用户不改源码就能配规则」。两者控制流一致,差别只在钩子体是函数还是
spawn。 - 只做 Pre/Post 两个时机:真实版还有
UserPromptSubmit / Stop / Notification等一整套生命周期点。我们聚焦工具生命周期这两个最能说明问题的,避免铺得太开。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
HookRegistry | utils/hooks/ 的钩子加载与执行 |
| 进程内函数钩子 | settings.json 配置的外部 shell 命令 |
Pre deny / modifyInput | 真实版 hook 返回 block / 修改输入 |
| Post 改写结果 | 真实版 PostToolUse 处理输出 |
| 仅 Pre/Post 两个时机 | 真实版还有 UserPromptSubmit / Stop / Notification 等一整套 |
# 七、一句话总结
Hooks = 在工具执行的固定缝隙里挂横切逻辑:Pre 靠返回值(deny/modifyInput)拦截或改写、Post 改写结果,钩子链顺序执行、任一 deny 短路、modify 累积;拦截必须以 tool_result 反馈给模型。它把安全、审计、自动修正从工具里剥离出来,集中且可配置——这正是 step42 Plan Mode 只读护栏的底座。
# 下一节预告
step37 加 TodoWrite 工具——让模型把复杂多步任务拆成一张可勾选清单,边做边更新,既是它的工作记忆,也是用户的进度面板。