← 返回文章

SOURCE NOTE · HOOK SYSTEM

Lesson 5: Hooks 子系统

Hook 是生命周期节点上的异步扩展机制:适合门禁、审计、通知与校验;但它不是能任意改写 prompt、工具入参或工具结果的通用中间件。

学完本课,你能回答:Hook 在何时运行?哪些 Hook 真能阻止主流程?四种 Hook 类型怎样工作?想做审计、外部策略校验或上下文保留时该落在哪里?

一句话概括

Hook 是一组按生命周期事件触发、按优先级串行运行的异步拦截器。它适合做门禁、审计、通知与校验;当前并不是可任意改写 prompt、工具入参或工具结果的通用中间件。

用户消息 → user_prompt_submit → LLM
                             │
                             ▼
                       tool_use 请求
                             │
                             ▼
                    pre_tool_use Hook  ← 审计 / 拦截
                             │
                             ▼
              查工具 → 校验参数 → Permissions → 确认
                             │
                             ▼
                         tool.execute()
                             │
                             ▼
                   post_tool_use Hook  ← 记录结果 / 通知
                             │
                             ▼
                      ToolResult 回写 LLM

与 Permissions 的分工很清楚:Permissions 是全局统一的安全裁决闸门;Hook 是在生命周期节点上追加行为的扩展机制。比如「永远禁止读取密钥」优先放 PermissionChecker;「执行 Bash 前交给公司服务额外审查」适合 pre_tool_use Hook。

下面的本地演示把这条边界拆开:选一个场景,看 Hook 是否只是在运行,还是它的结果真的被调用方消费,进而改变主流程。

INTERACTIVE TRACE · LOCAL SIMULATION

谁运行,谁真的能阻止?

选择事件场景,沿着匹配、执行和调用方裁决逐步查看。组件不调用真实 Hook、命令或模型。

pre_tool_use

EVENTpre_tool_use

TYPEcommand

Bash 想写入受保护路径。匹配 Hook 先于工具查找和参数校验运行;调用方消费聚合结果后,主执行路径停止。

STAGE 01PASS

QueryEngine._execute_tool_call()

发出 pre_tool_use 事件

模型提出的 tool_name 与原始 tool_input 先交给 Hook。此时两者尚未经过 registry 或 Pydantic 校验。

event: "pre_tool_use" · tool_name: "bash"
CALLER OUTCOME

阻止:错误 ToolResult 回给 LLM

tool.execute() 未到达

核心文件

事件地图:什么时机可插入?

事件名必须是以下 snake_case 值:

session_start       # 会话启动
user_prompt_submit  # 用户消息已提交
pre_compact         # 完整上下文压缩前
post_compact        # 压缩完成后
pre_tool_use        # 工具执行前
notification        # 即将显示权限确认提示时
post_tool_use       # 工具成功返回后
stop                # agent 正常结束时
subagent_stop       # 子 Agent 完成时
session_end         # 会话关闭时

哪些事件的 blocked 会真的改变主流程?

最关键:pre_tool_use 在哪里?

工具执行路径的实际顺序是:

pre_tool_use
  → registry.get(tool_name)
  → input_model.model_validate(tool_input)
  → PermissionChecker.evaluate(...)
  → 可选 notification + 用户确认
  → tool.execute(...)
  → post_tool_use

这带来两个结论:

  1. pre_tool_use 能在工具产生副作用前阻止它。
  2. 它比工具查找和 Pydantic 校验都早:Hook 可能收到不存在的工具名或格式错误的 tool_input,所以不能假定输入可信或合法。
execute-tool-call.py · simplifiedpython
pre_result = await hook_executor.execute("pre_tool_use", payload)
if pre_result.blocked:
  return ToolResultBlock(pre_result.reason, is_error=True)

# 只有未被调用方阻止时,才继续下面的路径。
tool = registry.get(tool_name)
parsed = tool.input_model.model_validate(tool_input)
decision = permission_checker.evaluate(...)
result = await tool.execute(parsed, context)
await hook_executor.execute("post_tool_use", result_payload)

四种 Hook 类型

command Hook 可使用两个运行时提供的信息:

OPENHARNESS_HOOK_EVENT      # 当前事件名
OPENHARNESS_HOOK_PAYLOAD    # JSON 字符串 payload

# 命令模板里的 $ARGUMENTS 会被替换成完整 JSON payload
python scripts/check_policy.py $ARGUMENTS

prompt / agent 期待模型返回类似:

{"ok": true}
// 或
{"ok": false, "reason": "目标路径不允许写入"}

优先级、matcher 与失败策略

每个 Hook 都能设置:

{
  "matcher": "bash",
  "priority": 100,
  "timeout_seconds": 10,
  "block_on_failure": true
}

最小配置:Bash 执行前的策略脚本

下面是概念上的 settings 配置:执行 Bash 前先运行本地校验,校验脚本非零退出、超时或无法启动时,阻止 Bash 工具。

settings.json · conceptualjson
{
"hooks": {
  "pre_tool_use": [
    {
      "type": "command",
      "command": "python scripts/check_bash_policy.py $ARGUMENTS",
      "matcher": "bash",
      "timeout_seconds": 10,
      "block_on_failure": true,
      "priority": 100
    }
  ]
}
}

它适合把组织策略放在一个可独立测试的脚本里,而非散落在每个 Bash 调用处。

工具审计的正确组合

pre_tool_use
  → 记录:模型想调用哪个工具、输入是什么
  → 若策略失败:阻止,不执行工具

工具执行成功
  ↓
post_tool_use
  → 记录:工具输出、是否为错误
  → 发 HTTP 到审计 / 监控服务

限制也要说清:post_tool_use 只会在工具走到 execute() 并正常返回后触发。若工具不存在、参数无效、权限拒绝、pre Hook 阻止或 execute() 抛异常,都不会走到这个正常的 post 事件。

此外,大输出会先被引擎 offload;所以 post Hook 对超长结果看到的可能是预览与 artifact 路径,而不一定是完整原文。

post_compact:现成的上下文注入点

绝大多数 Hook 输出不会自动影响模型上下文。但 post_compact 是重要例外:它的输出会收集为 Compact hook notes,附入压缩后的会话上下文。

适合注入:

延伸问答:事件 vs 类型(两个垂直维度)

这是最容易混淆的一组概念。四种 Hook 类型与 pre_tool_use 等事件不是同一层级:

                    ┌─ command:本地脚本
事件 pre_tool_use ──┼─ http:外部策略 / 审计服务
                    ├─ prompt:模型快速判断
                    └─ agent:模型深入判断

                    ┌─ command:本地记录脚本
事件 post_tool_use ─┼─ http:上报工具结果
                    ├─ prompt:结果质量检查
                    └─ agent:复杂结果分析

配置结构就是两个维度的笛卡尔组合:

事件名
  → 一组 Hook 定义
      → 每个定义各自声明 type 和 matcher

原则上四种类型都能挂到任何事件上;语义是否合理取决于目的。

一句话:pre_tool_use 决定「在工具执行前」;command 决定「通过本地命令处理」;两者组合才得到「工具执行前运行本地命令检查」的完整行为。

延伸问答:HookRegistry 的调用链

HookRegistry 本身有三条运行时路径:

  1. 构建ui/runtime.pybuild_runtime()load_hook_registry(settings, plugins) 建表,传给 HookExecutor
  2. 执行HookExecutor.execute() 通过 registry.get(event) 取该事件下按优先级排好的列表。
  3. 刷新:每条用户消息前 handle_line() 重新加载注册表并 update_registry();改 settings/插件后下一条消息即生效。

summary() 的链路则是:

用户输入 /hooks
  → commands/registry.py 的 _hooks_handler()
  → CommandContext.hooks_summary
  → RuntimeBundle.hook_summary()
  → load_hook_registry(...).summary()

summary() 只负责把当前生效的 Hook 配置格式化成可读文本给 /hooks 展示,不参与实际执行。内部 API client 路径另有 HookReloader 按配置文件 mtime 决定是否重建注册表。

推荐精读(一手源码)

主读:src/openharness/hooks/executor.py —— 执行、匹配、失败与四种 Hook 类型。

接着看: