# 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,会有三个致命问题:

  1. 散落且易漏:新加一个工具,很容易忘了补上危险命令检查。约束应该是默认全覆盖,而不是靠每个工具自觉。
  2. 耦合职责:Bash 工具本该只管跑命令,不该同时操心「审计日志格式」「脱敏规则」。这些逻辑混进去,工具就不纯粹了。
  3. 不可配置:真实 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 };   // 用新入参替换后继续
1
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 };
}
1
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 去执行 */ }
1
2
3
4
5
6
7
8

执行完再跑 Post 改写结果文本。

# 数据流

AI 决定调用工具
   ↓
权限检查 canUseTool
   ↓
★ PreToolUse 钩子链(顺序)→ deny?→ 生成 "blocked by hook" tool_result 喂回模型
   │                        → modify?→ 用新入参
   ↓
执行工具 tool.execute(input)   ← 这一步才允许并行(Promise.all)
   ↓
★ PostToolUse 钩子链(顺序)→ 改写结果文本
   ↓
结果入历史,进入下一轮
1
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 工具——让模型把复杂多步任务拆成一张可勾选清单,边做边更新,既是它的工作记忆,也是用户的进度面板。