这篇想弄清楚几件事:Hook 在何时运行?哪些 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 前交给公司服务额外审查」适合 Hook。
下面的本地演示把这条边界拆开:选一个场景,看 Hook 是否只是在运行,还是它的结果真的被调用方消费,进而改变主流程。
- 事件Event
- pre_tool_use
- 类型Type
- command
Bash 想写入受保护路径。匹配 Hook 先于工具查找和参数校验运行;调用方消费聚合结果后,主执行路径停止。
第 1 步Step 1放行Pass
QueryEngine._execute_tool_call()发出 pre_tool_use 事件模型提出的 tool_name 与原始 tool_input 先交给 Hook。此时两者尚未经过 registry 或 Pydantic 校验。
event: "pre_tool_use" · tool_name: "bash"- 调用方怎么处理What the caller does
- 阻止:错误 ToolResult 回给 LLM
tool.execute()未到达
源码地图
hooks/events.py:HookEvent,全部生命周期事件。hooks/schemas.py:四种 Hook 配置模型与公共字段。hooks/types.py:HookResult与多结果聚合。hooks/loader.py:加载 settings / plugins 的 Hook,并按 priority 排序。hooks/executor.py:,负责匹配、执行与失败处理。engine/query.py:工具调用前后与 stop 事件的真实调用点。services/compact/__init__.py:压缩前后 Hook,以及压缩附件注入。ui/runtime.py:创建 executor、会话启动与关闭事件。
Hook 能挂在哪些时刻
事件名必须是以下 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 会真的改变主流程?
- :调用方消费 。 工具不执行;阻止原因作为错误工具结果回给 LLM。
- :调用方消费 。 取消后续完整摘要压缩。
- :不作门禁。 输出会作为 compact hook notes 附进压缩后的上下文。
- 其余事件:通常不消费 。 它们通常只做观察、通知或副作用;返回的 不会中断该流程。
Hook 返回 blocked=True 本身不会自动停止世界。必须由对应调用方读取它。当前最重要的两道真门是 和 。
pre_tool_use 到底在哪一步
工具执行路径的实际顺序是:
pre_tool_use
→ registry.get(tool_name)
→ input_model.model_validate(tool_input)
→ PermissionChecker.evaluate(...)
→ 可选 notification + 用户确认
→ tool.execute(...)
→ post_tool_use
这带来两个结论:
- 能在工具产生副作用前阻止它。
- 它比工具查找和 Pydantic 校验都早:Hook 可能收到不存在的工具名或格式错误的
tool_input,所以不能假定输入可信或合法。
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,各适合干什么
- :启动本地命令;适合本地规则脚本、审计;默认
block_on_failure=false。 - :POST 事件 payload 到服务;适合策略服务、SIEM、远程日志;默认
block_on_failure=false。 - :额外请求模型作判断;适合自然语言规则校验;默认 。
- :以更深入提示词请求模型判断;适合较复杂的策略校验;默认 。
Hook 可使用两个运行时提供的信息:
OPENHARNESS_HOOK_EVENT # 当前事件名
OPENHARNESS_HOOK_PAYLOAD # JSON 字符串 payload
# 命令模板里的 $ARGUMENTS 会被替换成完整 JSON payload
python scripts/check_policy.py $ARGUMENTS
/ 期待模型返回类似:
{"ok": true}
// 或
{"ok": false, "reason": "目标路径不允许写入"}
Hook 不是启动一个带工具、可自主循环的子 Agent;当前实现只是一次额外的模型验证请求。
谁先跑,失败了怎么办
每个 Hook 都能设置:
{
"matcher": "bash",
"priority": 100,
"timeout_seconds": 10,
"block_on_failure": true
}
- 越大越先执行;同优先级保留注册顺序。settings Hook 通常先于插件 Hook。
- 用
fnmatch匹配。工具事件通常匹配tool_name,用户消息事件匹配 prompt,其他事件通常匹配事件名。 - 失败只有在 时才变成阻止结果。
- 即使前一个 Hook 已阻止,后续匹配 Hook 目前仍会执行;因此 Hook 自身不宜依赖「前面已拦截就绝不会运行」。
最小例子:执行 Bash 前先跑策略脚本
下面是概念上的 settings 配置:执行 Bash 前先运行本地校验,校验脚本非零退出、超时或无法启动时,阻止 Bash 工具。
{
"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 和 post 一起用
pre_tool_use
→ 记录:模型想调用哪个工具、输入是什么
→ 若策略失败:阻止,不执行工具
工具执行成功
↓
post_tool_use
→ 记录:工具输出、是否为错误
→ 发 HTTP 到审计 / 监控服务
限制也要说清: 只会在工具走到 execute() 并正常返回后触发。若工具不存在、参数无效、权限拒绝、pre Hook 阻止或 execute() 抛异常,都不会走到这个正常的 post 事件。
此外,大输出会先被引擎 offload;所以 post Hook 对超长结果看到的可能是预览与 artifact 路径,而不一定是完整原文。
适合记录结果、通知 SIEM 或检查质量,但不能撤销已经发生的工具副作用。真正的执行前门禁应放在 或 Permissions。
post_compact:现成的上下文注入点
绝大多数 Hook 输出不会自动影响模型上下文。但 是重要例外:它的输出会收集为 Compact hook notes,附入压缩后的会话上下文。
适合注入:
- 压缩后仍必须保留的安全约束;
- 外部部署环境状态;
- 当前任务不可丢失的关键事实。
如果你想让 Hook「改变后续模型知道什么」,优先研究 。如果你想真正重写工具参数或输出,则要显式扩展引擎的 Hook 结果契约,而不是假设现有 Hook 会自动替换数据。
事件管什么时候,类型管怎么做
这是最容易混淆的一组概念。四种 Hook 类型与 等事件不是同一层级:
- 事件(event):例如 、、;回答「什么时候触发」——时机。
- 类型(type):例如 、、、;回答「触发后怎么执行」——手段。
┌─ command:本地脚本
事件 pre_tool_use ──┼─ http:外部策略 / 审计服务
├─ prompt:模型快速判断
└─ agent:模型深入判断
┌─ command:本地记录脚本
事件 post_tool_use ─┼─ http:上报工具结果
├─ prompt:结果质量检查
└─ agent:复杂结果分析
配置结构就是两个维度的笛卡尔组合:
事件名
→ 一组 Hook 定义
→ 每个定义各自声明 type 和 matcher
原则上四种类型都能挂到任何事件上;语义是否合理取决于目的。
pre_tool_use + command:工具执行前跑本地策略脚本,可阻止。pre_tool_use + http:执行前询问企业审批 / 策略服务。post_tool_use + http:成功后上报审计,通常不阻止。post_compact + prompt:让模型判断压缩后仍该保留哪些规则。session_end + command:清理临时资源或提交会话统计。
一句话: 决定「在工具执行前」; 决定「通过本地命令处理」;两者组合才得到「工具执行前运行本地命令检查」的完整行为。
HookRegistry 怎么建、怎么用、怎么刷新
本身有三条运行时路径:
- 构建:
ui/runtime.py的build_runtime()调load_hook_registry(settings, plugins)建表,传给 。 - 执行:
HookExecutor.execute()通过registry.get(event)取该事件下按优先级排好的列表。 - 刷新:每条用户消息前
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 类型。
接着看:
src/openharness/hooks/events.py、schemas.py、types.py—— 事件、配置与结果契约;src/openharness/hooks/loader.py—— 注册来源与 priority 排序;src/openharness/engine/query.py—— / 的真实位置;src/openharness/services/compact/__init__.py—— compact Hook 的门禁和 notes 注入;tests/test_hooks/与tests/test_engine/test_query_engine.py—— 测试对照。