D4|Hooks:exit 2 拦住这次操作,不是再写一句 prompt
先别把 hook 当成 Skill 的定时版
读完 D1–D3,下一件错事是在 SKILL.md 里写「每次跑 shell 前先检查」,或在 AGENTS.md 里写「请不要 commit」。那是说明书。要在 工具真正执行前后 机械地插一刀,用 Hooks。
判断先说清楚:Hook 挂在生命周期事件上,不是模型上下文。 handler 是 command 或 mcp_tool。同步 handler 可以用 exit 2(stderr 写原因)或 stdout JSON 的 decision: block 拦住这次操作;也可以改 PreToolUse 的 input。项目 hook 跟项目 config 走同一道未信任闸;脚本内容还要用 信任哈希,改过就变成 Modified,不能静默接着跑。
它补的是 B3 ExecPolicy 管不到的「这次 call 的参数」,不是再给模型一段话。
1. 十二个事件,不是任意回调
公开名字在 HOOK_EVENT_NAMES(hooks/src/lib.rs:23-36),和协议枚举一致(protocol.rs:1578-1590):
| 事件 | 何时 | 典型用途 |
|---|---|---|
SessionStart / SessionEnd | 会话开/关 | 注入 extra context;收尾 |
UserPromptSubmit | 用户话要进 turn | 拦或给 prompt 加上下文 |
PreToolUse | 工具跑之前 | 拦、改 input |
PermissionRequest | 要人审批时 | Allow / Deny |
PostToolUse | 工具跑完 | 根据输出再拦后续 |
PreCompact / PostCompact | 压缩前后 | 日志;不能靠它改历史(B4 禁止 rewrite) |
SubagentStart / SubagentStop | 子代理生/死 | D5 |
Stop | 一轮正常停 | 可要求继续 |
Interrupt | 取消 | 短超时(测试里会 clamp 到 3s) |
matcher 只对其中 9 个有意义(HOOK_EVENT_NAMES_WITH_MATCHERS,:43-50):按工具名、compact 触发源、session 来源等过滤。没有 matcher 的事件不要指望用 glob 筛工具。
/hooks 查看和管理(slash_command.rs:111)。feature hooks 默认开(features/src/lib.rs:1173-1176)。声明可以在 hooks.json 或 config 的 [[hooks.PreToolUse]]。插件用 hooks/hooks.json(D2)。
没有「挂在事件上」会怎样?你只能改 prompt,模型下次可能不听;或把检查焊进每个 ToolHandler,B2 的四层又被打穿。
2. Handler:command 或 mcp_tool;只有同步能拦住
运行时两种(ConfiguredHandlerKind,hooks/src/engine/mod.rs:116-126):
- Command: 一条 shell,stdin 是事件 JSON,stdout/stderr 是结果;可标
async - McpTool: 调已配置 MCP 的某个 tool,参数模板从事件展开(
mcp_runner.rs:16-41)
协议上还有 Prompt / Agent(HookHandlerType,protocol.rs:1595-1599),那是对外枚举;这台引擎落地的是 command 和 mcp_tool。
只有同步 handler 能施加控制效果(can_apply_control_effects,mod.rs:152-156)。async 和 executor-scoped 钩子会跑,但不参与 block / 改 input。控制面必须在工具 spawn 之前返回,不能「后台想想再拦」。
没有这层会怎样?一个 30 秒的审计脚本把 PreToolUse 卡住,Turn 假死;或异步 hook 在命令已经跑完才说 block。
3. exit 2 = block;stdout JSON 可以改 input
PreToolUse 的完成路径(events/pre_tool_use.rs:235-276)是全书要抄的那张表:
| handler 怎么退出 | 效果 |
|---|---|
stdout 合法 JSON,decision/block_reason | HookRunStatus::Blocked,操作中止 |
| stdout JSON 且未 block | 可带 updated_input,改这次工具参数 |
| exit code 2 + stderr 非空 | 同样 Blocked,原因取 stderr |
| exit 2 但 stderr 空 | Failed(不是静默放行):必须写明为什么拦 |
| 其它非 0 | Failed,后续 hook 仍可能跑(看 FailedContinue vs abort) |
| 看起来像 JSON 但解析失败 | Failed |
UserPromptSubmit / PostToolUse / PermissionRequest / Stop 同样认 exit 2(各事件文件里同一句「exited with code 2 but did not write …」)。PermissionRequest 的 JSON 决策是 Allow / Deny(output_parser.rs:25-28);带未支持的改写字段会 fail closed(schema.rs:201-203)。
stdout 还可以回灌 additional_context(SessionStart、PreToolUse 等),进模型的是 hook 产出的 fragment,有 token 顶(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT)。这是有界注入,不是把 hook 日志整份拼进 prompt。
exit 2 和 B3 的 sandbox exit 2 不是一回事。沙箱那边 2/126/127 是「别当成沙箱拒绝去升级」;hook 这边 2 是 明确拦截。两个 2,两个协议。
没有「exit 2 / JSON 双通道」会怎样?脚本只能 0/1,1 分不清「失败了请继续」和「拦住」;或只能 JSON,shell 一行 exit 2 的 linter 用不了。兼容 Claude Code 风格的 hooks.json,就是为了这条机械拦截。
4. 信任哈希;未信任项目不加载项目 hook
来源(HookSource,protocol.rs:1618-1630):System / User / Project / Plugin / MDM / …。项目 hook 和项目 .codex/config.toml 同生共死:未信任时项目层 loaded but disabled,gated 文案写明包括 hooks(B5,loader/mod.rs:1078)。克隆仓库里的 hooks.json 默认不会跑——这是闸门一。
闸门二是 脚本内容的哈希。发现时算 current_hash(sha256:,测试 discovery.rs:1042),和用户(或托管)记下的 trusted_hash 比(:797-810):
| 状态 | 含义 |
|---|---|
Managed | 企业/内置,不靠用户点信任 |
Trusted | 哈希对得上(或 builtin) |
Modified | 曾经信过,文件变了 |
Untrusted | 还没信过 |
未信任的 handler 默认不启用;bypass_hook_trust 是测试/逃生口(:1476)。改 hook 脚本必须重新信任,否则变成「仓库里改一行 hook = 远程代码执行」。插件 hook 来自你 plugin add 过的包,来源是 Plugin,仍有自己的 hash。
没有哈希会怎样?你点过一次 Allow,攻击者改 PreToolUse 命令把 rm -rf 塞进去,下次会话静默执行。信任的是 这一份字节,不是文件名。
5. 和 ExecPolicy、Skill、审批怎么分工
| 层 | 拦什么 | 不拦什么 |
|---|---|---|
| ExecPolicy | 命令前缀、真实可执行路径;spawn 前 | 这次参数里的 URL、这次要写的文件 |
| Hook PreToolUse | 这次 call 的 payload;可改 input | 不能替代 OS 沙箱 |
| AskForApproval | 人在不在回路 | 人点 Allow 之后 hook 已经跑过或还要跑,看事件顺序 |
| Skill | 模型怎么做事 | 不执行、不 block |
| AGENTS.md | 每轮可见的项目政策 | 模型可以不听 |
危险前缀用 ExecPolicy forbidden。本次 apply_patch 的路径策略、本次 MCP 参数,用 PreToolUse。风格和流程用 Skill。不要用 hook 实现「请简洁」(那是 C4),也不要用 Skill 实现「禁止 sudo」。
PermissionRequest hook 插在 问人之前:可以自动 Deny,省一次弹窗;不要用它绕过 Never 政策去偷偷 Allow 网络——能力旋钮仍在 B3。
6. 结语:带走一句话
Hook 是生命周期上的机械拦截:同步 command/mcp_tool 用 exit 2 或 JSON 拦住(或改)这一次操作;项目 hook 跟未信任闸走,脚本还要过信任哈希——它补的是 ExecPolicy 看不到的参数,不是又一份 prompt。