Hook 生命周期模式
问题
如果没有中心化的 hook 生命周期,扩展性会退化为混乱:hook 作者在 Agent 执行的任意点附加副作用,没有一致的信任执行,并发 hook 之间没有确定性排序,也没有结构化的方式将阻止决策传回调用者。外部 hook 可能在工作区信任建立之前执行,导致从不受信的配置文件进行远程代码执行。长时间运行的 hook 如果在没有正确注册表交接的情况下自行转为后台运行,会留下孤儿进程,在意想不到的时间传递过时或矛盾的信号。
这些问题出现在任何支持可扩展 hook 点的 Agent 运行时中——并非特定于某个具体实现。
黄金法则
所有 hook 流经单一分发点
永远不要让 hook 执行分散在多个调用处。单一分发函数是信任检查、来源合并、类型路由和结果聚合的咽喉。如果添加新的 hook 类型,分发点是唯一需要更改的地方。分散的执行处在信任执行或结果处理上不可避免地会偏离。
信任是全有或全无的
在任何 hook 触发之前,恰好检查一次工作区信任。如果信任尚未建立,跳过所有 hook——不仅仅是外部的。包括进程内 hook。这防止了一种半信任状态,其中某些 hook 执行而另一些不执行,这种状态比干净地全部跳过更难调试,产生不可预测的行为。
多个来源以显式优先级合并
Hook 来自多个来源:持久化配置、编程 SDK 注册和临时的会话作用域注册。将它们合并为具有定义优先级分层的单一有序列表。当企业策略限制 hook 时,整个来源层被干净地排除,而非逐个过滤 hook。
退出码承载语义权重
对于外部进程 hook,为特定退出码保留特定含义。一个码表示成功。一个特定码表示"阻止此操作并将我的错误消息注入 Agent 的上下文"。所有其他非零码表示"警告用户但不阻止"。这种三级约定让外部脚本无需结构化 JSON 输出即可控制 Agent 行为。
拒绝优于询问优于允许
当同一批次中的多个 hook 返回冲突的权限决策时,应用严格的优先级:拒绝覆盖询问,询问覆盖允许,穿透永远不设置聚合决策。这种确定性解析在 hook 不一致时消除了歧义。
会话 hook 在设计上是临时的
在运行时为特定 Agent 会话注册的 hook 必须作用域限定于该会话的 ID,并在会话结束时自动清理。它们永远不应写入持久化配置。如果子 Agent 注册了一个 hook,该 hook 不能在父 Agent 的会话或兄弟子 Agent 的会话中触发。这种隔离防止并行扇出期间的跨会话副作用。
适用场景
- 你的 Agent 运行时暴露了外部代码可以 hook 的生命周期事件。
- 你需要任何 hook 类型都无法绕过的信任执行。
- Hook 来自多个必须确定性合并的配置来源。
- 你支持同步阻塞 hook 和长时间运行的后台 hook。
- 多个 hook 可能对同一操作返回冲突的权限决策。
- 你需要会话结束时自动清理的会话作用域 hook。
权衡
| 决策 | 收益 | 代价 |
|---|---|---|
| 单一分发点 | 信任、路由和聚合集中一处 | 每种 hook 类型都必须符合相同的分发接口 |
| 全有或全无的信任门控 | 无半信任状态,简单的心智模型 | 不构成安全风险的进程内 hook 也被阻止 |
| 多来源合并与优先级 | 干净的策略执行,可预测的排序 | hook 作者必须理解自己属于哪个来源层 |
| 多种 hook 类型 | 每项工作有合适的工具(进程、LLM、HTTP、进程内) | 更多类型面需要文档和维护 |
| 异步生成器组合 | 流式部分结果,阻塞错误可早期退出 | 调用者必须处理增量累积 |
| 通过注册表交接的后台化 | 长时间运行的 hook 不阻塞主循环 | 如果跳过注册表交接有孤儿风险 |
| 会话作用域 hook | 临时 hook 在会话结束时自动清理 | 作用域限定于一个会话的 hook 不在子 Agent 会话中触发 |
| 跨来源去重 | 同一 hook 出现在多个配置中时不会重复触发 | 去重键设计必须考虑来源特定前缀 |
实现模式
- 将每次 hook 调用路由到单一分发函数。此函数是信任检查、来源合并、类型分发和结果聚合的唯一位置。
- 将信任检查实现为分发中的第一个操作。如果信任缺失,立即返回空结果——不评估哪些 hook 本应匹配。
- 按定义的优先级顺序合并 hook 来源(如持久化配置、SDK 注册、会话作用域)。当策略标志限制为仅管理的 hook 时,排除整个来源层而非过滤单个条目。
- 使用包含来源特定上下文的复合键跨来源去重。来自不同插件的两个相同命令的 hook 是不同的;来自用户配置和项目配置的相同命令折叠为后者优先的条目。
- 至少支持以下 hook 类型类别:外部进程(shell 命令)、LLM 子调用、子 Agent 委派、HTTP 端点、具有完整输出控制的进程内回调,以及用于简单允许/拒绝决策的轻量布尔门控。
- 使用异步生成器组合并行运行所有匹配的 hook。每个 hook 在可用时产出类型化的结果片段。分发函数合并所有生成器并向调用者产出聚合的片段。
- 对于外部进程 hook,将退出码映射到语义结果:一个码表示成功,一个保留码表示阻塞错误(错误消息在 stderr 上),所有其他非零码表示非阻塞警告。
- 支持两种后台化模式:静态(在 hook 配置中于执行前声明)和动态(hook 将后台化信号作为第一行输出发出)。后台化的 hook 被交接到待处理 hook 注册表,以便跨轮次跟踪。
- 将会话 hook 作用域限定于特定的 Agent 或会话 ID。主会话下注册的 hook 不在子 Agent 会话中触发。提供显式移除函数,但也在会话结束时自动清除所有会话 hook。
- 聚合多个 hook 的权限决策时,应用严格优先级:拒绝优于询问,询问优于允许,穿透被忽略。
- 根据所需能力选择 hook 类型。使用外部进程 hook 进行 shell 级副作用。使用 LLM 子调用 hook 当 hook 本身需要推理时。使用 HTTP hook 进行外部服务集成。使用进程内回调当 hook 需要对结构化输出字段(权限决策、注入上下文、输入重写)的完全控制时。使用轻量布尔门控当你只需要对对话历史进行是/否决策而无需 JSON 序列化开销时。
- 注册进程级清理处理器在关闭时终止所有运行中的 hook。没有这个,后台化的 hook 会在父进程之后存活成为孤儿。
- 防范事件类型限制:某些事件可能与某些 hook 类型不兼容。例如,在早期会话设置期间的 HTTP hook 可能在响应消费者准备好之前触发事件时死锁。在分发层而非依赖 hook 作者自行发现来记录这些限制。
踩坑指南
信任门控阻止所有 hook 类型,不仅仅是外部的。 每个 hook——包括进程内回调和布尔门控——在信任缺失时都被跳过。永远不要因为 hook 在进程内运行就假设它已触发。如果 hook 未触发,首先检查信任状态。
缺失的外部脚本可能产生与故意阻止相同的退出码。 如果 hook 脚本在注册和执行之间被删除,shell 本身可能以保留的阻塞码退出。在注册时而非仅在执行时验证 hook 脚本存在。
策略限制模式静默丢弃会话 hook。 当企业策略将 hook 限制为仅管理来源时,技能或子 Agent 注册的会话作用域 hook 收到零匹配且无错误。管理和非管理部署之间的测试行为可能不同。
带"唤醒"语义的后台 hook 绕过待处理注册表。 正常后台化的 hook 出现在注册表中,在下一轮之前被解决。唤醒式 hook 设计上跨轮次存活——它们在以阻塞退出码完成时注入任务通知来唤醒模型,而非出现在待处理 hook 注册表中。不要对必须在下一次工具调用前完成的 hook 使用唤醒。
布尔门控 hook 不可序列化。 仅返回 true/false 的轻量进程内门控本质上是临时的。它们被排除在配置快照、分析和遥测之外。如果需要持久性或可观测性,使用完整回调类型。
去重使用后者优先,可能让多作用域作者意外。 当相同的 hook 命令同时出现在用户级和项目级配置中时,项目级条目胜出。去重键中的来源特定前缀防止跨来源冲突,但同来源冲突被静默解决。
HTTP hook 在早期生命周期事件中可能死锁。 如果 HTTP hook 为会话启动或设置事件注册,出站请求可能在响应消费者准备好之前触发,造成死锁。分发层应显式过滤不兼容的 hook 类型/事件类型组合,而非留给 hook 作者处理。
会话 hook 在会话清理后不可见。 一旦会话结束且其 hook 被清除,不留下它们曾存在的记录。如果调试需要理解过去会话中哪些 hook 是活跃的,你需要单独的日志——会话存储本身是临时的。
Claude Code 实证
Claude Code 的 hook 系统是这些原则的生产实现,支持六种 hook 类型,覆盖数十个生命周期事件。
带信任门控的单一分发。 所有 hook 执行流经一个异步生成器函数。它做的第一件事是检查工作区信任。在交互模式下,这要求用户已接受信任对话框。在 SDK(非交互)模式下,信任是隐含的。如果信任缺失,生成器立即返回——不触发任何类型的 hook。
三层来源合并与策略执行。 Hook 按优先级顺序从三个来源提取:来自设置文件的快照 hook、SDK 注册的回调 hook,以及存储在基于 Map 的会话状态中的会话作用域 hook。当企业策略设置仅管理标志时,会话和插件 hook 在来源合并层被排除,而非逐个过滤。这意味着策略执行是一个单一条件,丢弃整个来源列表,而非需要为每种新 hook 类型维护的逐 hook 过滤。
流式结果的异步生成器组合。 每个 hook 作为独立的异步生成器运行,产出类型化的结果片段——阻塞错误、权限决策、额外上下文、修改后的输入。并行合并工具组合所有生成器,使调用者在片段到达时接收它们。这对阻塞错误至关重要:批次中的慢 hook 不会延迟快 hook 的阻塞信号。
基于 Map 的会话状态用于并发。 会话 hook 对会话存储使用 Map 而非普通对象。Map 修改返回相同的容器引用,因此应用存储的相等性检查看不到变化,存储的监听器都不触发。这避免了并行子 Agent 扇出期间的二次方监听器通知成本,此时许多 hook 可能快速连续注册。设计教训是通用的:当存储支持的数据结构在并发操作期间接收频繁写入时,选择一个其修改不触发变更检测级联的容器。
两种模式的后台化。 Hook 可以静态后台化(在配置中声明)或动态后台化(hook 在做实际工作之前发出后台化信号作为第一行输出)。更进一步的变体,"唤醒"后台化,用于必须跨多个 Agent 轮次存活的 hook——在以阻塞退出码完成时,它们注入任务通知来唤醒模型,而非出现在待处理 hook 注册表中。这种三级后台化设计反映了现实世界中 hook 生命周期的范围:同轮次、下一轮次和跨多轮次。
权限优先级。 当多个 hook 对同一工具使用事件返回冲突的权限决策时,聚合遵循严格优先级:拒绝覆盖询问,询问覆盖允许,穿透被忽略。这确保单个注重安全的 hook 始终可以否决,无论有多少宽松的 hook 也回应。
六种 hook 类型各有独特角色。 系统支持命令 hook(shell 进程)、prompt hook(LLM 子调用)、Agent hook(完整子 Agent 委派)、HTTP hook(要求 JSON 响应的外部服务调用)、回调 hook(具有完整结构化输出控制的进程内函数),以及函数 hook(仅对对话历史的会话级布尔门控)。回调/函数的分离是刻意的:回调遵循与外部 hook 相同的协议,可以影响权限、注入上下文和重写输入。函数 hook 仅接收消息历史并返回布尔值——它们存在是为了无 JSON 序列化开销的轻量验证,且永远不被持久化或包含在遥测中。
退出码纪律。 外部命令 hook 遵循严格的三级约定:退出 0 是成功,退出 2 是阻塞错误(其 stderr 消息被注入模型的上下文),任何其他非零退出是非阻塞警告——向用户显示但不停止执行。这一约定让 shell 脚本作者仅使用进程退出码就能细粒度地控制 Agent 行为。
来源感知键的去重。 当相同的 hook 命令出现在多个设置作用域(用户、项目、工作区本地)时,合并层使用复合键去重。对于来自同一来源的 hook,后者优先。对于来自不同插件根的 hook,去重键中的来源特定前缀防止跨插件冲突被静默折叠。