Skip to content

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_reasonHookRunStatus::Blocked,操作中止
stdout JSON 且未 block可带 updated_input,改这次工具参数
exit code 2 + stderr 非空同样 Blocked,原因取 stderr
exit 2 但 stderr 空Failed(不是静默放行):必须写明为什么拦
其它非 0Failed,后续 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。