English 书架

第 12 章:Hooks 与人工审批

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

Codex hooks 与 approval surfaces:展示 tool request、pre-tool hook、policy、permission hook、approval、sandbox、post-tool hook 和 evidence
Hooks 和 approvals 相邻,但回答的是不同问题:automation 可以观察或标注什么,policy 要求什么,谁授权 side effect,attempt 在哪里受约束,以及什么 evidence 回到 turn。

源码边界: 本章解释固定在 commit 569ff6a1 的 OpenAI Codex 公开快照。文件路径、函数、枚举、request struct 与 event shape 只有在链接到这个 commit 时才算 verified source。代码块都是为了阅读而裁剪的短摘录;完整定义以旁边的固定源码链接为准。gate stackauthority boundaryhuman 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 状态;它会收到一等的 HookStartedHookCompleted runtime facts。

这是本章第一个不变量:hook effect 是 event-local 的。Pre-tool hook 可以说“不要运行这个 tool call”或“加入这段 context”。Permission-request hook 可以回答某个具体 approval request。Post-tool hook 可以针对具体结果返回 feedback。这些能力彼此相邻,但不会自动互相推出。

二、Discovery 先列出,再由 trust 过滤运行时

Hook discovery 与 trust filter:展示 system、user、project、plugin、registry、trust gate、active hooks、modified hooks 和 disabled hooks
Hook discovery 的 listing metadata 比 runtime authority 更宽:modified 与 disabled hooks 可以继续可见,但只有 enabled managed 或 trusted hooks 会成为 active handlers。

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, &current_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;否则是 ModifiedUntrustedhook_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 内部

Permission request hook flow:展示 tool asks、policy、permission hook、deny wins、allow、no decision、guardian、user 和 events
Permission-request hook 不是换了名字的 pre-tool hook:它在 policy 产生 approval request 之后、普通 Guardian 或用户审批路径之前运行。

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 包含 ExecApprovalPatchApproval,二者都带 submission id 和 ReviewDecision。Runtime events 包含 ExecApprovalRequestApplyPatchApprovalRequest,所以 client 能展示正确 surface,而不必自己发明 transport。

决定是否可能提示用户的 policy 是 AskForApproval。它区分 UnlessTrustedOnFailureOnRequestGranularNever。这里的注释就是 contract 的一部分:Never 表示 Codex 不询问用户,失败直接返回而不是升级;Granular 可以在某个字段为 false 时自动拒绝对应 prompt category。

5.2 Decision enum 保留 recovery semantics

Response 比 yes/no 丰富得多。ReviewDecision 保留了多个 outcome:

DecisionRuntime meaning
Approved授权当前 request。
ApprovedForSession在 session-scoped approval cache 内授权等价后续 prompts。
ApprovedExecpolicyAmendment批准并持久化 proposed exec-policy amendment。
NetworkPolicyAmendment对同一 host 的未来 requests 持久化 allow 或 deny rule。
Denied拒绝 action,让 session 尝试别的办法。
TimedOutAutomated review 在 deadline 前没有完成。
Abort停止,直到用户下一条 command。

这个 shape 避免了两个常见实现 bug。第一,session-scoped approval 不能被拍平成“永久批准”;它有 cache scope。第二,timeout 不能渲染成“用户拒绝”;Guardian timeout 有不同 recovery message,也有不同 analytics。

六、Orchestrator 把 approval 放在 sandbox 之前

Tool orchestrator flow:展示 requirement、skip、forbidden、needs approval、decision、sandbox attempt、sandbox denied、retry approval 和 no-sandbox retry
Orchestrator 先解析 approval requirement,再选择 sandbox attempt;后续 sandbox denial 可能在 no-sandbox retry 前再次请求 approval。

核心 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 SandboxAttemptorchestrator.rs)。

Rejection step 也保留 decision semantics。reject_if_not_approved 区分 DeniedAbortTimedOut;而 ApprovedApprovedForSession、exec-policy amendments 与 allow-network amendments 可以继续。Network amendment 如果是 deny action,仍然会 reject。源码再一次避免了 boolean approval shortcut。

七、Guardian 是 reviewer,有自己的失败边界

Guardian review boundary:展示 approval request、review id、review session、read only、approved、denied、timed out、abort、warning 和 event ledger
Guardian review 由 approval policy 和 reviewer config 选择,在受约束的 review session 中运行,并把 timeout、abort、denial 与 approval 作为不同状态上报。

7.1 Guardian 由 policy 和 reviewer config 选择

Guardian 不是每个 tool call 内的隐式分支。routes_approval_to_guardian 只有在 approval policy 是 OnRequestGranular,且 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。ReviewDecisionGuardianAssessmentStatus 保留这些状态。UI 无法解释到底是 risk、time 还是 cancellation 停止了 action。
Never approval policy 表示“全部信任”。Protocol 表示 Codex 永不询问用户;policy conflict 会返回 failure,而不是升级。Headless 或 non-interactive run 永远等待一个不存在的 modal。

应用到实践

可以迁移到其他 agent runtime 的规则也很直接:

  1. 把 automation listing 和 runnable automation 分开。 Discovery 应该暴露 untrusted 与 modified hooks,但不能执行它们。
  2. 把 validation hooks 和 approval hooks 分开。 Block、context injection 和 allow decision 的 blast radius 不同。
  3. 把 approval 建模成 enum,不要建模成 boolean。 Session cache、policy amendment、network amendment、denial、timeout 和 abort 都需要不同处理。
  4. 让 automated reviewer 在受约束 session 中运行。 一个可以 mutate 自己所 review 状态的 reviewer,不是真 reviewer。
  5. 让 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 eventsVerified sourceprotocol.rs
Legacy hook payload 对照Verified sourcetypes.rs
Hook list 与 runnable handler 拆分Verified sourceengine/mod.rs / discovery.rs
Pre-tool hook block/context outcomeVerified sourcehook_runtime.rs / pre_tool_use.rs
User-prompt hook 与 additional-context recordingVerified sourcehook_runtime.rs
Permission-request hook decision foldVerified sourcepermission_request.rs
Approval protocol inputs 与 requestsVerified sourceUserInput / EventMsg
Permission hook before Guardian/user approvalVerified sourcerequest_approval
Approval policy 与 decisionsVerified sourceAskForApproval / ReviewDecision
Approval requirement before first sandbox attemptVerified sourceorchestrator.rs
Sandbox-denial retry branchVerified sourceorchestrator.rs
Guardian routing、fail-closed review 与 constrained sessionVerified sourcereview.rs