第 12 章:Hooks 与人工审批
阅读契约: 用本章区分四个很容易被混在一起的边界:观察事件的 hook 程序、可以回答审批提示的 permission-request hook、授权副作用的 approval decision,以及真正约束执行的 sandbox attempt。读完以后,你应该能解释为什么 hook list 不是可运行权限列表,为什么 approval 不等于绕过 sandbox,以及为什么 Guardian review 有自己的失败状态。

源码边界: 本章解释固定在 commit 569ff6a1 的 OpenAI Codex 公开快照。文件路径、函数、枚举、request struct 与 event shape 只有在链接到这个 commit 时才算 verified source。代码块都是为了阅读而裁剪的短摘录;完整定义以旁边的固定源码链接为准。gate stack、authority boundary、human surface 这类说法是从可见源码得出的 surrounding contract inference,不是对 OpenAI 私有服务内部的断言。
第 11 章停在一个已经验证过的 patch:runtime 解析了编辑语言,评估了被触碰的文件,通过自己的 filesystem owner 应用了 hunks,并记录了最终 delta。这里还剩一个实际问题:当 Codex 已经理解某个动作之后,什么东西还能阻止、修正或解释这个风险动作?
直觉答案是 approval。这个答案太粗。Pre-tool hook 可以在普通审批路由前阻断命令。Permission-request hook 可以在 Guardian 或用户看到审批请求前给出答案。Approval protocol 可以区分 approve once、approve for session、deny、timeout 和 policy amendment。Guardian 可以自动 review 某些审批请求,但它的 timeout 不等同于用户拒绝。最后,sandbox 仍然可以拒绝一个已批准的 action,因为 approval 和 enforcement 是两个 owner。
源码围绕这种分离来组织。Hooks 是 event-scoped programs。Approval 是 decision shape。Guardian 是由 policy 选择的 automated reviewer。Sandboxing 是 execution attempt 的约束。把它们都叫作泛泛的 “safety middleware” 会让描述变简单,但会让系统更难推理。
一、Hooks 是事件,不是泛化权限
1.1 Event vocabulary 才是边界
公开 runtime 通过两个相关但不同的表面暴露 hook work。这个快照里的旧 codex-rs/hooks/src/types.rs HookEvent enum 只带 legacy AfterAgent payload。当前 hook engine 使用的更完整 runtime event vocabulary 在 protocol 层的 HookEventName:
pub enum HookEventName {
PreToolUse,
PermissionRequest,
PostToolUse,
PreCompact,
PostCompact,
SessionStart,
UserPromptSubmit,
Stop,
}
这个差异很重要,因为 “hook” 并不表示任意代码可以在任意地方运行。每个事件都有自己的 request shape 和 outcome contract。PreToolUseRequest 接收 tool name、matcher aliases、tool id 和 JSON tool input;结果可以 block,也可以补充 model context。PostToolUseRequest 接收 tool response,可以在工具运行后补充 feedback。UserPromptSubmitRequest 在用户 prompt 被 turn 接受前运行。StopRequest 可以把一个即将停止的 turn 变成带 continuation fragments 的继续工作。
1.2 Hook run 是 typed runtime fact
共享的事件 envelope 体现在 HookRunSummary。一个 hook run 有 id、event name、handler type、execution mode、scope、source path、source、display order、status、timing 和 entries。Client 不需要从随机 stderr 里猜 hook 状态;它会收到一等的 HookStarted 和 HookCompleted runtime facts。
这是本章第一个不变量:hook effect 是 event-local 的。Pre-tool hook 可以说“不要运行这个 tool call”或“加入这段 context”。Permission-request hook 可以回答某个具体 approval request。Post-tool hook 可以针对具体结果返回 feedback。这些能力彼此相邻,但不会自动互相推出。
二、Discovery 先列出,再由 trust 过滤运行时

Hook registry 刻意把 listing 和 execution 分开。ClaudeHooksEngine 存 runnable ConfiguredHandlers 和 warnings。HookListEntry 则携带用户可见 metadata:event name、handler type、matcher、command、timeout、source、plugin id、display order、enabled state、current hash 和 trust status。
2.1 Listing metadata 比 runtime authority 更宽
这个拆分不是装饰。一个 list entry 可以是 untrusted、modified 或 disabled。它仍然值得展示给用户看,但不应该静默变成可执行 runtime code。append_matcher_groups 会为每个 command hook 计算 normalized identity hash,然后决定它能不能变成 runnable handler:
let current_hash = command_hook_hash(event_name, matcher, &group, normalized_handler);
let enabled = hook_enabled(source.is_managed, state);
let trusted_hash = hook_trusted_hash(source.is_managed, state);
let trust_status = hook_trust_status(source.is_managed, ¤t_hash, trusted_hash);
hook_entries.push(HookListEntry { enabled, current_hash, trust_status, .. });
if enabled && matches!(trust_status, HookTrustStatus::Managed | HookTrustStatus::Trusted) {
handlers.push(ConfiguredHandler { event_name, matcher, command, .. });
}
这个摘录只保留 trust boundary:metadata 先进入 hook_entries,随后只有最后这个 if 会把 command 移入 runnable handlers。
2.2 Trust 是 hash-matched execution claim
Trust function 很小,但很硬。Managed source 直接是 Managed。非 managed hook 只有在 stored trusted hash 和 current normalized hash 相等时才是 Trusted;否则是 Modified 或 Untrusted(hook_trust_status)。这个 normalized identity 不是 raw source bytes;command_hook_hash 序列化 event name、matcher group、timeout、async flag、status message 与 command identity,让 TOML 和 hooks.json 形式收敛到同一个 trust record。
因此,一个操作结论很明确:“hook 出现在列表里”不等于它会运行。Runtime authority 还要求 hook feature enabled、handler 受支持、hook enabled,并且 trust status 是 managed 或 trusted。
三、Runtime hooks 会先 preview,再 resolve
3.1 Preview 产生 pending state
当某个 hook event 触发,Codex 会先问 registry 哪些 handlers 会匹配,发出 pending view,再运行 handlers 并发出 completion。run_pre_tool_use_hooks 展示了这个形状:
let request = PreToolUseRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
permission_mode: hook_permission_mode(turn_context),
tool_name: tool_name.name().to_string(),
matcher_aliases: tool_name.matcher_aliases().to_vec(),
tool_use_id,
tool_input: tool_input.clone(),
..
};
let preview_runs = hooks.preview_pre_tool_use(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
let outcome = hooks.run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
这个摘录省略了 transcript path、model 和 post-run context recording,让 lifecycle 更清楚:构造 typed request、preview matching handlers、执行 handlers、再发 completion。
Preview 不是 UI 润色。慢 hook 否则会看起来像 tool call 卡死;blocking hook 否则会看起来像 handler 内部失败。事件模型让 terminal、app-server 和 headless client 都能在最终 outcome 落地前展示“hook work pending”。
3.2 Block/context 不是 approval
PreToolUse 最容易被误读。它的 parser 可以把 hook result 转成 should_block、block reason 和 additional model context(pre_tool_use.rs)。这是 hook block,不是 approval decision。如果 pre-tool hook block,工具不会进入 approval routing。如果 hook 只是添加 context,普通 policy 和 approval 仍然要继续运行。
同样的 pattern 出现在 turn 的其他边界。inspect_pending_input 会在 pending user input 被接受前运行 user-prompt-submit hooks。record_additional_contexts 把 hook-provided context 存成 developer messages。Runtime 没有把 hooks 当成非结构化 stderr;它把 hook outcomes 翻译成 typed turn consequences。
四、Permission-request hook 位于 approval 内部

4.1 Request 仍然是 hook event
本章最关键的 hook,恰好也是最像 approval 的 hook。permission_request.rs 直接写清楚了 contract:这个 event 在 approval path 中运行,发生在 Guardian 或 user approval UI 被展示之前。和 PreToolUse 不同,它不会 rewrite tool input,也不会通过停止执行来 block;它可以返回一个具体 allow/deny decision,或者拒绝决策,让正常 approval flow 继续。
它的 request shape 带着 policy hook 需要推理动作的身份信息:session id、turn id、cwd、transcript path、model、permission mode、tool name、matcher aliases、run id suffix 和 tool input(PermissionRequestRequest)。Decision type 刻意很小:
pub enum PermissionRequestDecision {
Allow,
Deny { message: String },
}
它的 fold rule 很保守。resolve_permission_request_decision 把任何 deny 当成最终结果。如果没有 handler deny,allow 可以批准请求。如果没有 handler 决策,approval 会继续进入普通 route。
4.2 Orchestrator 让 permission hook 先回答
具体 orchestration 点在 ToolOrchestrator::request_approval:
if evaluate_permission_request_hooks
&& let Some(permission_request) = tool.permission_request_payload(req)
{
match run_permission_request_hooks(...).await {
Some(PermissionRequestDecision::Allow) => return Ok(ReviewDecision::Approved),
Some(PermissionRequestDecision::Deny { message }) => {
return Err(ToolError::Rejected(message));
}
None => {}
}
}
let decision = tool.start_approval_async(req, approval_ctx).await;
Ok(decision)
这段代码只保留 decision order。完整函数还会记录 telemetry,并区分后续 decision 是来自 Guardian 还是用户审批路径。
这里 ordering 变得很具体。Permission-request hooks 优先回答 approval prompts。只有 unresolved case 才会落到 Guardian 或用户审批。因此,把 hooks 只描述成“approval 前的额外校验”是不准确的:有一类 hook 本身就位于 approval routing 内部。
五、Approval 是协议决策,不只是 modal
5.1 Approval 通过 protocol 流动
Approval request 和 approval response 都是 protocol input,不只是 UI dialog。UserInput 包含 ExecApproval 与 PatchApproval,二者都带 submission id 和 ReviewDecision。Runtime events 包含 ExecApprovalRequest 与 ApplyPatchApprovalRequest,所以 client 能展示正确 surface,而不必自己发明 transport。
决定是否可能提示用户的 policy 是 AskForApproval。它区分 UnlessTrusted、OnFailure、OnRequest、Granular 和 Never。这里的注释就是 contract 的一部分:Never 表示 Codex 不询问用户,失败直接返回而不是升级;Granular 可以在某个字段为 false 时自动拒绝对应 prompt category。
5.2 Decision enum 保留 recovery semantics
Response 比 yes/no 丰富得多。ReviewDecision 保留了多个 outcome:
| Decision | Runtime meaning |
|---|---|
Approved | 授权当前 request。 |
ApprovedForSession | 在 session-scoped approval cache 内授权等价后续 prompts。 |
ApprovedExecpolicyAmendment | 批准并持久化 proposed exec-policy amendment。 |
NetworkPolicyAmendment | 对同一 host 的未来 requests 持久化 allow 或 deny rule。 |
Denied | 拒绝 action,让 session 尝试别的办法。 |
TimedOut | Automated review 在 deadline 前没有完成。 |
Abort | 停止,直到用户下一条 command。 |
这个 shape 避免了两个常见实现 bug。第一,session-scoped approval 不能被拍平成“永久批准”;它有 cache scope。第二,timeout 不能渲染成“用户拒绝”;Guardian timeout 有不同 recovery message,也有不同 analytics。
六、Orchestrator 把 approval 放在 sandbox 之前

核心 ordering 位于 ToolOrchestrator::run。这个函数先从工具或默认 policy 计算 ExecApprovalRequirement,然后处理三种 shape:
6.1 Approval requirement 先发生
let requirement = tool.exec_approval_requirement(req)
.unwrap_or_else(|| default_exec_approval_requirement(...));
match requirement {
ExecApprovalRequirement::Skip { .. } => { /* maybe strict auto-review */ }
ExecApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
let decision = Self::request_approval(...).await?;
Self::reject_if_not_approved(..., decision).await?;
already_approved = true;
}
}
这段摘录刻意停在 pre-sandbox 阶段。此时 orchestrator 还没有选择 platform sandbox;它只是在判断这个 request 是否允许被尝试。
只有这个 approval phase 结束后,orchestrator 才会选择 first sandbox attempt(orchestrator.rs)。这是本章第二个不变量:approval 决定 Codex 是否可以尝试 side effect;sandboxing 决定这个 attempt 在哪里、如何受约束。
6.2 Sandbox denial 是新分支,不是 approval failure
Sandbox-denial branch 证明了这种区别。如果 first attempt 因 sandbox denial 失败,runtime 会检查 tool 是否能 escalate、approval policy 是否允许 no-sandbox approval,以及是否存在 network denial context。只有这些条件满足后,它才会请求 retry approval,并创建 SandboxType::None 的 escalated SandboxAttempt(orchestrator.rs)。
Rejection step 也保留 decision semantics。reject_if_not_approved 区分 Denied、Abort 与 TimedOut;而 Approved、ApprovedForSession、exec-policy amendments 与 allow-network amendments 可以继续。Network amendment 如果是 deny action,仍然会 reject。源码再一次避免了 boolean approval shortcut。
七、Guardian 是 reviewer,有自己的失败边界

7.1 Guardian 由 policy 和 reviewer config 选择
Guardian 不是每个 tool call 内的隐式分支。routes_approval_to_guardian 只有在 approval policy 是 OnRequest 或 Granular,且 config 选择 ApprovalsReviewer::AutoReview 时,才把 approval prompt 路由给 reviewer。Orchestrator 也可能为某个 turn 启用 strict auto-review,但那仍然会产生单独的 guardian_review_id 和普通 ReviewDecision。
7.2 Fail-closed 不会压扁状态
Review function 明确 fail closed。run_guardian_review 会发送 in-progress GuardianAssessment event,运行 review session,并把 explicit allow/deny、timeout、cancellation、prompt-build failure、session failure 和 parse failure 映射成不同 terminal behavior。Timeout 返回 ReviewDecision::TimedOut;cancellation 返回 ReviewDecision::Abort;build/session/parse failure 会变成 high-risk deny,而不是隐式 allow。
7.3 Review session 受约束
Review session 本身也受约束。run_guardian_review_session 的注释写明,Guardian 不应该 mutate state 或触发进一步 approval,所以这个 session 被固定到 read-only sandbox,approval_policy = never,并关闭非必要 agent features。它可以复用 parent 的 managed-network allowlist 做 read-only checks,但不会继承 exec-policy rules。
因此 Guardian 的角色很窄:对 approval request 做 automated review。它不是 tool runtime owner,不是 sandbox,也不是静默的 policy amendment engine。用户可见表面仍然会收到 structured events 和 warnings,主工具路径最终消费的仍然是一个 ReviewDecision。
八、这套设计阻止了什么
这套架构比单个“问用户”callback 复杂,但每个部件都在阻止一个具体失败模式。
8.1 常见误读
| 误读 | 源码校正 | 避免的失败 |
|---|---|---|
| Hook list 就是 runnable handler list。 | HookListEntry 记录更宽的 metadata;只有 enabled managed/trusted entries 会变成 ConfiguredHandlers。 | Modified project automation 在变更后静默运行。 |
| Pre-tool hook 就是 approval。 | PreToolUseOutcome 负责 block 或 add context;permission-request hook 才在 approval path 中产生 allow/deny decision。 | 一个 validation hook 意外变成 authority grant。 |
| Approval 意味着 no sandbox。 | Orchestrator 在选择 sandbox attempt 之前解析 approval;no-sandbox retry 是单独分支。 | 已批准命令默认逃出 containment。 |
| Guardian denial、timeout 和 abort 都只是 failed。 | ReviewDecision 与 GuardianAssessmentStatus 保留这些状态。 | UI 无法解释到底是 risk、time 还是 cancellation 停止了 action。 |
Never approval policy 表示“全部信任”。 | Protocol 表示 Codex 永不询问用户;policy conflict 会返回 failure,而不是升级。 | Headless 或 non-interactive run 永远等待一个不存在的 modal。 |
应用到实践
可以迁移到其他 agent runtime 的规则也很直接:
- 把 automation listing 和 runnable automation 分开。 Discovery 应该暴露 untrusted 与 modified hooks,但不能执行它们。
- 把 validation hooks 和 approval hooks 分开。 Block、context injection 和 allow decision 的 blast radius 不同。
- 把 approval 建模成 enum,不要建模成 boolean。 Session cache、policy amendment、network amendment、denial、timeout 和 abort 都需要不同处理。
- 让 automated reviewer 在受约束 session 中运行。 一个可以 mutate 自己所 review 状态的 reviewer,不是真 reviewer。
- 让 sandboxing 保持第二 owner。 Approval 授权 attempt;containment 仍然可以 reject 或强制进入新的 approval path。
第 13 章会跟随一个已批准 attempt 进入 containment 层:permission profiles、filesystem/network policy、platform sandboxes、managed networking,以及 remote execution metadata。
源码地图
| 概念 | 证据类型 | 源码锚点 |
|---|---|---|
| Hook event names 与 run events | Verified source | protocol.rs |
| Legacy hook payload 对照 | Verified source | types.rs |
| Hook list 与 runnable handler 拆分 | Verified source | engine/mod.rs / discovery.rs |
| Pre-tool hook block/context outcome | Verified source | hook_runtime.rs / pre_tool_use.rs |
| User-prompt hook 与 additional-context recording | Verified source | hook_runtime.rs |
| Permission-request hook decision fold | Verified source | permission_request.rs |
| Approval protocol inputs 与 requests | Verified source | UserInput / EventMsg |
| Permission hook before Guardian/user approval | Verified source | request_approval |
| Approval policy 与 decisions | Verified source | AskForApproval / ReviewDecision |
| Approval requirement before first sandbox attempt | Verified source | orchestrator.rs |
| Sandbox-denial retry branch | Verified source | orchestrator.rs |
| Guardian routing、fail-closed review 与 constrained session | Verified source | review.rs |