中文 Books

Chapter 12: Hooks and Human Approval

Reading Contract: Use this chapter to separate four boundaries that are easy to collapse: hook programs that observe events, permission-request hooks that may answer approval prompts, approval decisions that authorize side effects, and sandbox attempts that still constrain execution. After reading, you should be able to explain why a hook list is not a runnable authority list, why an approval is not a sandbox bypass, and why Guardian review has its own failure states.

Codex hooks and approval surfaces showing tool request, pre-tool hook, policy, permission hook, approval, sandbox, post-tool hook, and evidence
Hooks and approvals are adjacent, but they answer different questions: what automation may observe or annotate, what policy requires, who authorizes the side effect, where the attempt is constrained, and what evidence returns to the turn.

Source boundary: this chapter explains the public OpenAI Codex snapshot pinned at commit 569ff6a1. File paths, functions, enums, request structs, and event shapes are verified source when linked to that commit. Code blocks are short, trimmed excerpts for reading; use the nearby pinned links for complete definitions. Terms such as “gate stack”, “authority boundary”, and “human surface” are surrounding contract inference from visible source, not claims about private OpenAI service internals.

Chapter 11 ended at a verified patch: the runtime had parsed an edit language, assessed the files being touched, applied the hunks through its filesystem owner, and recorded the committed delta. That still leaves a practical question: what can stop or reshape a risky action after Codex understands what the action is?

The tempting answer is “approval”. That is too coarse. A pre-tool hook can block a command before normal approval routing. A permission-request hook can answer an approval prompt before Guardian or a user sees it. The approval protocol can distinguish “approved once”, “approved for this session”, “denied”, “timed out”, and policy amendments. Guardian can review some approval requests automatically, but its timeout is not the same as a user denial. Finally, the sandbox can still deny an approved action because approval and enforcement are separate owners.

The source is designed around that separation. Hooks are event-scoped programs. Approval is a decision shape. Guardian is an automated reviewer selected by policy. Sandboxing is the execution attempt’s constraint. Treating them as one generic “safety middleware” makes the system easier to describe and harder to reason about.

1. Hooks Are Events, Not General Authority

1.1 The Event Vocabulary Is the Boundary

The public runtime exposes hook work through two related but different surfaces. The older codex-rs/hooks/src/types.rs HookEvent enum in this snapshot only carries a legacy AfterAgent payload. The broader runtime event vocabulary used by the current hook engine is the protocol-level HookEventName:

pub enum HookEventName {
    PreToolUse,
    PermissionRequest,
    PostToolUse,
    PreCompact,
    PostCompact,
    SessionStart,
    UserPromptSubmit,
    Stop,
}

That distinction matters because “hook” does not mean “arbitrary code runs anywhere”. Each event has a request shape and outcome contract. PreToolUseRequest receives the tool name, matcher aliases, tool id, and JSON tool input; its outcome can block and return extra model context. PostToolUseRequest receives the tool response and can add feedback after the tool has run. UserPromptSubmitRequest operates before a user prompt is accepted into the turn. StopRequest can turn a would-be stop into continuation fragments.

1.2 A Hook Run Is a Typed Runtime Fact

The shared event envelope is visible in HookRunSummary. A hook run has an id, event name, handler type, execution mode, scope, source path, source, display order, status, timing, and entries. The client does not have to infer hook state from a random terminal line; it receives HookStarted and HookCompleted events as first-class runtime facts.

This is the first invariant of the chapter: hook effects are event-local. A pre-tool hook can say “do not run this tool call” or “add this context”. A permission-request hook can answer a specific approval request. A post-tool hook can return feedback about a specific result. None of those capabilities automatically imply the others.

2. Discovery Produces a List, Then Trust Filters Runtime

Hook discovery and trust filter showing system, user, project, plugin, registry, trust gate, active hooks, modified hooks, and disabled hooks
Hook discovery keeps listing metadata broader than runtime authority: modified and disabled hooks can remain visible while only enabled managed or trusted hooks become active handlers.

The hook registry is deliberately split between listing and execution. ClaudeHooksEngine stores runnable ConfiguredHandlers and warnings. HookListEntry, by contrast, carries user-facing metadata: event name, handler type, matcher, command, timeout, source, plugin id, display order, enabled state, current hash, and trust status.

2.1 Listing Metadata Is Wider Than Runtime Authority

That split is not cosmetic. A list entry can be untrusted, modified, or disabled. It still matters to show the user that it exists, but it should not silently become executable runtime code. The discovery path in append_matcher_groups computes a normalized identity hash for each command hook and then decides whether it can become a 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, .. });
}

The excerpt is trimmed to the trust boundary: metadata is pushed into hook_entries before the runnable-handler filter, and only the final if moves a command into handlers.

2.2 Trust Is a Hash-Matched Execution Claim

The trust function itself is small and strict. Managed sources become Managed. Non-managed hooks become Trusted only when the stored trusted hash equals the current normalized hash; otherwise they are Modified or Untrusted (hook_trust_status). The normalized identity is not raw source bytes; command_hook_hash serializes the event name, matcher group, timeout, async flag, status message, and command identity so TOML and hooks.json forms converge on the same trust record.

The operational consequence is simple: “the hook appears in a list” is not evidence that it will run. Runtime authority requires the hook feature to be enabled, the handler to be supported, the hook to be enabled, and the trust status to be managed or trusted.

3. Runtime Hooks Are Previewed Before They Resolve

3.1 Preview Produces the Pending State

When a hook event fires, Codex first asks the registry which handlers would match, emits a pending view, then runs the handlers and emits completion. run_pre_tool_use_hooks shows the shape:

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;

This excerpt omits transcript path, model, and post-run context recording so the lifecycle stays visible: construct the typed request, preview matching handlers, execute them, then emit completion.

The preview step is not merely UI polish. A slow hook can otherwise look like a frozen tool call. A blocking hook can otherwise appear to have failed “inside” the handler. The event model lets terminal, app-server, and headless clients show “hook work is pending” before the final outcome lands.

3.2 Block and Context Are Not Approval

PreToolUse is the easiest event to misread. Its parser can convert a hook result into should_block, a block reason, and additional model context (pre_tool_use.rs). That is a hook block, not an approval decision. The tool never reaches approval routing if the pre-tool hook blocks. If the hook merely adds context, normal policy and approval still run.

The same pattern appears at other turn edges. inspect_pending_input runs user-prompt-submit hooks before pending user input is accepted. record_additional_contexts stores hook-provided context as developer messages. The runtime is not treating hooks as unstructured stderr; it is translating hook outcomes into typed turn consequences.

4. Permission-Request Hooks Sit Inside Approval

Permission request hook flow showing tool asks, policy, permission hook, deny wins, allow, no decision, guardian, user, and events
A permission-request hook is not a pre-tool hook with a different name: it runs after policy has produced an approval request and before the normal Guardian or user approval path.

4.1 The Request Is Still a Hook Event

The most important hook in this chapter is the one that sounds most like approval. permission_request.rs states its contract directly: the event runs in the approval path before Guardian or user approval UI is shown. Unlike PreToolUse, it does not rewrite input or block by stopping execution; it can return a concrete allow or deny decision, or decline to decide.

Its request shape carries the same identity information a policy hook needs to reason about the action: session id, turn id, cwd, transcript path, model, permission mode, tool name, matcher aliases, run id suffix, and tool input (PermissionRequestRequest). Its decision type is intentionally tiny:

pub enum PermissionRequestDecision {
    Allow,
    Deny { message: String },
}

The fold rule is conservative. resolve_permission_request_decision treats any deny as final. If no handler denies, an allow can approve the request. If no handler decides, approval continues through the normal route.

4.2 The Orchestrator Gives Permission Hooks First Answer

The orchestration point is 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)

The snippet is shortened to the decision order. The full function also records telemetry and chooses whether a later decision came from Guardian or the user-facing approval path.

This is where the ordering becomes concrete. Permission-request hooks take top precedence for answering approval prompts. Only the unresolved case falls through to Guardian or user approval. That is why it is wrong to describe hooks as merely “extra validation before approval”: one hook family lives inside approval routing.

5. Approval Is a Protocol Decision, Not a Modal

5.1 Approval Travels Through the Protocol

Approval requests and approval responses are protocol input, not only UI dialogs. UserInput includes ExecApproval and PatchApproval, both carrying a submission id and a ReviewDecision. Runtime events include ExecApprovalRequest and ApplyPatchApprovalRequest, so clients can present the right surface without inventing their own transport.

The policy that decides whether prompts are possible is AskForApproval. It distinguishes UnlessTrusted, OnFailure, OnRequest, Granular, and Never. The comments are part of the contract: Never means Codex does not ask the user and failures are returned instead of escalated; Granular can automatically reject prompt categories when a field is false.

5.2 The Decision Enum Preserves Recovery Semantics

The response is richer than yes/no. ReviewDecision preserves several outcomes:

DecisionRuntime meaning
ApprovedAuthorize the current request.
ApprovedForSessionAuthorize equivalent future prompts in the session-scoped approval cache.
ApprovedExecpolicyAmendmentApprove and persist a proposed exec-policy amendment.
NetworkPolicyAmendmentPersist an allow or deny rule for future requests to the same host.
DeniedReject the action and let the session try something else.
TimedOutAutomated review did not finish before its deadline.
AbortStop until the user’s next command.

This shape prevents two common implementation bugs. First, a session-scoped approval cannot be flattened into “approved forever”; it has a cache scope. Second, a timeout cannot be rendered as “user denied”; Guardian timeout has a different recovery message and different analytics.

6. The Orchestrator Keeps Approval Before Sandbox

Tool orchestrator flow showing requirement, skip, forbidden, needs approval, decision, sandbox attempt, sandbox denied, retry approval, and no-sandbox retry
The orchestrator first resolves the approval requirement, then selects a sandbox attempt; a later sandbox denial may ask again before a no-sandbox retry.

The central ordering lives in ToolOrchestrator::run. The function computes an ExecApprovalRequirement from the tool or default policy, then handles three shapes:

6.1 Approval Requirement Comes First

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;
    }
}

The excerpt is intentionally pre-sandbox. At this point the orchestrator has not chosen the platform sandbox; it is only deciding whether the request may be attempted.

Only after this approval phase does the orchestrator select the first sandbox attempt (orchestrator.rs). That separation is the second invariant of the chapter: approval decides whether Codex may try the side effect; sandboxing decides where and how the attempt is constrained.

6.2 Sandbox Denial Is a New Branch, Not Proof of Approval Failure

The sandbox-denial branch proves the distinction. If the first attempt fails with a sandbox denial, the runtime checks whether the tool can escalate, whether the approval policy allows a no-sandbox approval, and whether network denial context exists. Only then does it ask for retry approval and create an escalated SandboxAttempt with SandboxType::None (orchestrator.rs).

The rejection step also preserves decision semantics. reject_if_not_approved treats Denied, Abort, and TimedOut differently, while Approved, ApprovedForSession, exec-policy amendments, and allow-network amendments can proceed. A network amendment with a deny action still rejects. Again, the source avoids a boolean approval shortcut.

7. Guardian Is a Reviewer With Its Own Failure Boundary

Guardian review boundary showing approval request, review id, review session, read only, approved, denied, timed out, abort, warning, and event ledger
Guardian review is selected by approval policy and reviewer configuration, runs in a constrained review session, and reports timeout, abort, denial, and approval as distinct states.

7.1 Guardian Is Selected by Policy and Reviewer Config

Guardian is not an implicit branch inside every tool call. routes_approval_to_guardian routes approval prompts to the reviewer only when the approval policy is OnRequest or Granular and the configuration selects ApprovalsReviewer::AutoReview. The orchestrator may also enable strict auto-review for a turn, but that still produces a separate guardian_review_id and a normal ReviewDecision.

7.2 Fail-Closed Does Not Collapse States

The review function deliberately fails closed. run_guardian_review sends an in-progress GuardianAssessment event, runs the review session, and maps explicit allow/deny, timeout, cancellation, prompt-build failure, session failure, and parse failure into distinct terminal behavior. A timeout returns ReviewDecision::TimedOut; cancellation returns ReviewDecision::Abort; build/session/parse failures become a high-risk deny rather than an implicit allow.

7.3 The Review Session Is Constrained

The review session itself is constrained. The comments on run_guardian_review_session state that Guardian should not mutate state or trigger further approvals, so the session is pinned to a read-only sandbox with approval_policy = never and nonessential agent features disabled. It may reuse the parent’s managed-network allowlist for read-only checks, but it intentionally runs without inherited exec-policy rules.

That gives Guardian a narrow role: automated review of an approval request. It is not the owner of the tool runtime, not a sandbox, and not a silent policy amendment engine. The user-visible surface still receives structured events and warnings, and the main tool path still consumes a ReviewDecision.

8. What This Design Prevents

The architecture is more complex than a single “ask the user” callback, but each piece prevents a specific failure mode.

8.1 Common Misreadings

MisreadingSource-backed correctionFailure avoided
A hook list is a runnable handler list.HookListEntry records broad metadata; only enabled managed/trusted entries become ConfiguredHandlers.Modified project automation silently runs after a change.
A pre-tool hook is approval.PreToolUseOutcome blocks or adds context; permission-request hooks produce allow/deny decisions in the approval path.A validation hook accidentally becomes an authority grant.
Approval means no sandbox.The orchestrator resolves approval before selecting a sandbox attempt; no-sandbox retry is a separate branch.Approved commands escape containment by default.
Guardian denial, timeout, and abort are all “failed”.ReviewDecision and GuardianAssessmentStatus keep those states distinct.The UI cannot explain whether risk, time, or cancellation stopped the action.
Never approval policy means “trust everything”.The protocol says Codex never asks the user; policy conflicts return failures instead of escalation.Headless or non-interactive runs wait forever for a modal that cannot exist.

Apply This

The transferable rule is to preserve ownership boundaries in your own agent runtimes:

  1. List automation separately from runnable automation. Discovery should expose untrusted and modified hooks without executing them.
  2. Keep validation hooks separate from approval hooks. A block, a context injection, and an allow decision have different blast radii.
  3. Model approval as an enum, not a boolean. Session cache, policy amendments, network amendments, denial, timeout, and abort need different handling.
  4. Run automated reviewers in constrained sessions. A reviewer that can mutate the same state it reviews is not a reviewer.
  5. Let sandboxing remain a second owner. Approval authorizes an attempt; containment can still reject or force a new approval path.

Chapter 13 follows the approved attempt into that containment layer: permission profiles, filesystem and network policy, platform sandboxes, managed networking, and remote execution metadata.

Source Map

ConceptEvidence classSource anchor
Hook event names and run eventsVerified sourceprotocol.rs
Legacy hook payload contrastVerified sourcetypes.rs
Hook list vs runnable handler splitVerified sourceengine/mod.rs / discovery.rs
Pre-tool hook block/context outcomeVerified sourcehook_runtime.rs / pre_tool_use.rs
User-prompt hook and additional-context recordingVerified sourcehook_runtime.rs
Permission-request hook decision foldVerified sourcepermission_request.rs
Approval protocol inputs and requestsVerified sourceUserInput / EventMsg
Permission hook before Guardian/user approvalVerified sourcerequest_approval
Approval policy and decisionsVerified sourceAskForApproval / ReviewDecision
Approval requirement before first sandbox attemptVerified sourceorchestrator.rs
Sandbox-denial retry branchVerified sourceorchestrator.rs
Guardian routing, fail-closed review, and constrained sessionVerified sourcereview.rs