Skip to content

Approval Flow

This page documents the interactive approval flow — how each tool presents permission requests to the user, captures their decision, and routes it back to the agent loop. The permissions/index page covers the underlying policy model (rules, actions, evaluation). This page covers the UX layer on top of that model: what the user actually sees, what keys they press, and how the response propagates. For sandbox-tier behavior that determines which operations trigger approval in the first place, see Sandbox Modes. For model-initiated clarification prompts (a separate mechanism), see Questions.

Every AI coding agent needs a human-in-the-loop gate for dangerous operations. The challenge is not just whether to ask, but how to ask: what information to show, what options to offer, how to handle batches of related requests, and how to remember decisions within a session. A bad approval flow interrupts the user constantly; a good one minimizes friction while maintaining safety.

The three reference implementations take radically different approaches: Aider uses inline terminal prompts with batch grouping. Codex builds a modal TUI overlay with queuing and keyboard shortcuts. OpenCode separates the concern across a client-server boundary, with the TUI subscribing to permission events over SSE and replying over HTTP.


Aider’s approval flow centers on a single function: confirm_ask() in aider/io.py:807.

def confirm_ask(
self,
question,
default="y",
subject=None,
explicit_yes_required=False,
group=None,
allow_never=False,
):

Six parameters control everything:

  • question: The prompt text (e.g., “Run shell command?”, “Create new file?”, “Add URL to the chat?”).
  • default: What happens when the user presses Enter with no input. Usually "y", meaning approval is the default.
  • subject: Optional context displayed above the question in bold. For shell commands this is the command text; for files it is the file path; for URLs the URL. If the subject is multiline, all lines are padded to equal length for visual alignment (io.py:850-856).
  • explicit_yes_required: When True, the --yes-always auto-approve flag defaults to "n" instead of "y". Used for destructive operations — shell commands use this, file creation does not.
  • group: A ConfirmGroup instance that enables batch responses (“All” / “Skip all”). Explained below.
  • allow_never: Adds a “Don’t ask again” (D) option that remembers the refusal for the rest of the session.

The prompt is assembled as a single string with inline options:

(Y)es/(N)o [Yes]:

When group mode is active (multiple items):

(Y)es/(N)o/(A)ll/(S)kip all/(D)on't ask again [Yes]:

When explicit_yes_required is set, the (A)ll option is removed (you cannot batch-approve destructive operations), but (S)kip all remains.

Aider uses prompt_toolkit for styled input with color support (io.py:876-881). On dumb terminals (no TTY), it falls back to Python’s input() (io.py:882-883). The @restore_multiline decorator (io.py:57-71) temporarily disables multiline mode during the confirmation prompt, restoring it afterward.

  1. Empty input → use default (io.py:889-891)
  2. Ctrl+D (EOF) → use default (io.py:884-887)
  3. First character extracted after lowercasing (io.py:900)
  4. Prefix matching validates against ["yes", "no", "skip", "all", "don't"] (io.py:893)
  5. Invalid input → error message and re-prompt (io.py:897-898)

At io.py:866-869, the auto-approve flag is checked before any user interaction:

if self.yes is True:
res = "n" if explicit_yes_required else "y"
elif self.yes is False:
res = "n"

The explicit_yes_required inversion is the key design decision: --yes-always approves file creation automatically but refuses shell commands automatically. This prevents an autonomous agent from running arbitrary commands without human oversight.

The ConfirmGroup dataclass (io.py:81-89) enables batch processing for collections of similar items:

@dataclass
class ConfirmGroup:
preference: str = None
show_group: bool = True
def __init__(self, items=None):
if items is not None:
self.show_group = len(items) > 1

When a group has only one item, show_group = False hides the batch options. When the user answers “All” or “Skip all”, the preference field is set and subsequent calls in the same loop return immediately using the stored preference (io.py:870-872).

Three batch patterns in the codebase:

  1. Shell commands (base_coder.py:2439): ConfirmGroup(set(self.shell_commands)) — groups multiple commands from a single model response.
  2. URL mentions (base_coder.py:972): ConfirmGroup(urls) — groups auto-detected URLs.
  3. File mentions (base_coder.py:1770): ConfirmGroup(new_mentions) — groups auto-detected file references.

When the user selects “Don’t ask again” (D), the (question, subject) tuple is stored in self.never_prompts (a Python set, io.py:269). On subsequent calls, confirm_ask checks this set before prompting (io.py:823-824) and returns False immediately. This memory is session-scoped — it does not persist to disk.

LocationQuestionSubjectexplicit_yesgroupallow_never
base_coder.py:2456”Run shell command(s)?”\n.join(commands)YesYesYes
base_coder.py:2207”Create new file?“pathNoNoNo
base_coder.py:2226”Allow edits to unadded file?“pathNoNoNo
base_coder.py:972”Add URL to chat?“urlNoYesYes
base_coder.py:1770”Add file to chat?“rel_fnameNoYesYes
base_coder.py:2479”Add command output to chat?”NoneNoNoYes
architect_coder.py:17”Edit the files?”NoneNoNoNo
commands.py:389”Fix lint errors in {fname}?”NoneNoNoNo

The pattern: destructive operations use explicit_yes_required=True; batch-capable operations use group; operations the user might permanently dismiss use allow_never=True.


Codex builds a full ratatui modal overlay system for approvals. The implementation spans three key files:

  • Protocol types: codex-rs/protocol/src/approvals.rs
  • TUI overlay: codex-rs/tui/src/bottom_pane/approval_overlay.rs
  • Event routing: codex-rs/tui/src/chatwidget.rs

Codex defines three protocol-level approval events in approvals.rs:

1. ExecApprovalRequestEvent (approvals.rs:58-82) — triggered when the agent wants to run a shell command:

pub struct ExecApprovalRequestEvent {
pub call_id: String,
pub turn_id: String,
pub command: Vec<String>,
pub cwd: PathBuf,
pub reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
pub parsed_cmd: Vec<ParsedCommand>,
}

Two sub-variants exist based on context: normal command approval and network-restricted approval (when the sandbox detected an outbound connection attempt).

2. ApplyPatchApprovalRequestEvent (approvals.rs:103-118) — triggered when the agent wants to write files:

pub struct ApplyPatchApprovalRequestEvent {
pub call_id: String,
pub turn_id: String,
pub changes: HashMap<PathBuf, FileChange>,
pub reason: Option<String>,
pub grant_root: Option<PathBuf>,
}

The changes map contains per-file diffs. grant_root allows requesting write access to an entire directory subtree.

3. ElicitationRequestEvent (approvals.rs:84-93) — triggered when an MCP server needs information from the user:

pub struct ElicitationRequestEvent {
pub server_name: String,
pub id: RequestId,
pub message: String,
}

ApprovalOverlay (approval_overlay.rs:62-73) is a modal view pushed onto the bottom pane’s view stack:

pub(crate) struct ApprovalOverlay {
current_request: Option<ApprovalRequest>,
current_variant: Option<ApprovalVariant>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
options: Vec<ApprovalOption>,
current_complete: bool,
done: bool,
features: Features,
}

Key architectural details:

  • queue: Multiple approval requests can arrive while the user is deciding on a previous one. They are queued and presented sequentially.
  • list: Reuses the ListSelectionView widget for rendering selectable options with keyboard navigation.
  • current_complete: Guards against double-processing the same selection.
  • done: When true, the overlay is popped from the view stack.

Three functions (approval_overlay.rs:467-571) define the options for each approval type:

Exec (normal command):

  1. “Yes, proceed” — ReviewDecision::Approved — shortcut: Y
  2. “Yes, and don’t ask again for commands that start with <prefix>” — ReviewDecision::ApprovedExecpolicyAmendment — shortcut: P (only shown if a proposed_execpolicy_amendment is provided, and the rendered prefix is single-line)
  3. “No, and tell Codex what to do differently” — ReviewDecision::Abort — shortcut: Esc or N

Exec (network-restricted):

  1. “Yes, just this once” — ReviewDecision::Approved — shortcut: Y
  2. “Yes, and allow this host for this session” — ReviewDecision::ApprovedForSession — shortcut: A
  3. “No, and tell Codex what to do differently” — ReviewDecision::Abort — shortcut: Esc or N

Patch (file writes):

  1. “Yes, proceed” — ReviewDecision::Approved — shortcut: Y
  2. “Yes, and don’t ask again for these files” — ReviewDecision::ApprovedForSession — shortcut: A
  3. “No, and tell Codex what to do differently” — ReviewDecision::Abort — shortcut: Esc or N

MCP Elicitation:

  1. “Yes, provide the requested info” — ElicitationAction::Accept — shortcut: Y
  2. “No, but continue without it” — ElicitationAction::Decline — shortcut: N
  3. “Cancel this request” — ElicitationAction::Cancel — shortcut: Esc or C

The overlay header is built in ApprovalRequestState::from() (approval_overlay.rs:352-425):

  • Exec commands: Displayed with $ prefix and bash syntax highlighting via highlight_bash_to_lines(). If a reason is provided, it appears above the command as “Reason: reason” in italics.
  • Patches: Rendered as a DiffSummary showing per-file changes with file paths and change counts.
  • MCP elicitation: Shows “Server: server_name” in bold, then the message text.

The footer hint reads: “Press Enter to confirm or Esc to cancel”.

Three input paths (approval_overlay.rs:246-286):

  1. Direct shortcuts (try_handle_shortcut): Alphabetic keys (Y, N, P, A, C) trigger selection immediately by matching against each option’s shortcut list.
  2. Ctrl+A: Opens a full-screen popup for the current approval request (approval_overlay.rs:250-260), useful for reviewing long diffs.
  3. List navigation: Arrow keys navigate the option list; Enter confirms the highlighted option.

Ctrl+C behavior (approval_overlay.rs:289-317): Sends ReviewDecision::Abort for the current request and clears the entire queue. All pending approvals are cancelled.

When the user selects an option, apply_selection() (approval_overlay.rs:175-205) dispatches to one of three handlers:

fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone());
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
turn_id: None,
decision,
}));
}

The decision flows through the AppEvent channel as an Op variant (ExecApproval, PatchApproval, or ResolveElicitation), which the main event loop dispatches to codex_core for processing.

For exec decisions, a history cell is also inserted into the chat transcript, creating a visual record of what the user approved (e.g., a styled “You approved codex to run …” entry).

The request lifecycle from agent to overlay:

  1. Agent emits event → arrives at chatwidget.rs:on_exec_approval_request() (~line 1810).
  2. Defer-or-handle gate: If the TUI is still streaming LLM output, the request is queued for later. If idle, it proceeds immediately.
  3. Convert to UI request: handle_exec_approval_now() (~line 2387) flushes any in-progress streaming, creates a Notification, and builds an ApprovalRequest enum variant.
  4. Push to bottom pane: push_approval_request() (bottom_pane/mod.rs:770-787) either:
    • Enqueues into an existing overlay via try_consume_approval_request(), or
    • Creates a new ApprovalOverlay modal and pushes it onto the view stack.
  5. Timer pause: When a modal is active, the status indicator timer pauses (bottom_pane/mod.rs:785) to prevent spinner animation during the approval wait.
  6. User decidesapply_selection()advance_queue() either pops the next request or marks the overlay as done.

The five decision variants:

VariantMeaningScope
ApprovedExecute/apply onceThis request only
ApprovedForSessionExecute/apply and cache for sessionSession-wide
ApprovedExecpolicyAmendmentApprove and save prefix rulePersistent (written to execpolicy)
DeniedReject silentlyThis request only
AbortReject with user feedbackThis request + clears queue

OpenCode’s approval flow is split across the client-server boundary. The server manages permission state and blocks tool execution. The TUI client subscribes to permission events over SSE, renders the dialog, and replies over HTTP.

The core state machine lives in packages/opencode/src/permission/next.ts. Each instance maintains:

const state = Instance.state(() => ({
pending: {}, // Record<id, { info: Request, resolve, reject }>
approved: stored, // Ruleset — session-scoped "always" rules
}));

The pending map stores unresolved permission requests as Promise-backed entries. The approved array accumulates session-scoped “always allow” rules.

When a tool needs permission, it calls PermissionNext.ask() (next.ts:131-161):

  1. For each pattern in the request, evaluate against the combined ruleset (config rules + session-approved rules).
  2. If deny → throw DeniedError immediately.
  3. If allow → continue to next pattern.
  4. If ask → create a Promise, store it in pending, and publish a permission.asked bus event. The tool’s execution blocks until the Promise resolves or rejects.

This is a blocking-promise pattern: the tool coroutine is suspended until the user responds. No polling, no timeout.

export type Request = {
id: string, // Ascending permission ID
sessionID: string,
permission: string, // "edit", "read", "bash", "doom_loop", etc.
patterns: string[], // Specific patterns being requested
metadata: Record<string, any>, // Tool-specific context
always: string[], // Patterns for the "allow always" rule
tool?: { messageID: string, callID: string }
}

The metadata field carries type-specific context: for edit permissions it includes filepath and diff; for bash it includes description and command; for grep the search pattern.

The always field defines what patterns get added to the session ruleset when the user selects “Allow always”. For most tools this is ["*"] (all patterns). For bash commands, it is a command prefix generated by BashArity.prefix() (e.g., "git checkout *", "npm run *").

The TUI syncs permission state via SSE events in sync.tsx (~lines 107-147):

  • permission.asked: Binary-searches the local permission array by ID. Inserts at the sorted position if new, or reconciles if already known.
  • permission.replied: Binary-searches and removes the resolved permission from the local array.

The session component (session/index.tsx:126-133) aggregates permissions from all child sessions:

const permissions = createMemo(() => {
if (session()?.parentID) return [];
return children().flatMap((x) => sync.data.permission[x.id] ?? []);
});

Only parent sessions render permissions — child sessions inherit from the parent. When permissions are pending, the prompt input is blocked (index.tsx:1118).

PermissionPrompt (session/permission.tsx:119-298) is a Solid.js component with three stages:

  1. "permission" — Initial display showing the request details and three options.
  2. "always" — Confirmation dialog explaining that “Allow always” lasts until restart.
  3. "reject" — Text input for an optional rejection reason.

Keyboard handling (permission.tsx:393-425):

KeyAction
Left / HNavigate to previous option
Right / LNavigate to next option
EnterSelect current option
EscapeTrigger rejection
Ctrl+FToggle fullscreen diff view

Options presented (permission.tsx:267):

  • “Allow once” → reply: "once"
  • “Allow always” → enters confirmation stage, then reply: "always"
  • “Reject” → enters rejection reason stage (for child sessions) or direct reply: "reject"

Each permission type has a custom renderer (permission.tsx:201-264):

PermissionIconDisplay
editFile path + split/unified diff view
read“Read {filepath}“
globGlob pattern
grepGrep pattern
bash$Command description + full command text
taskSubagent type + description
webfetchURL
websearchSearch query
doom_loop“Continue after repeated failures”

The edit permission is the most complex: it renders a diff viewer with adaptive mode (split view if terminal width > 120 characters, unified otherwise). The diff style can be forced to stacked via config.tui?.diff_style === "stacked". Syntax highlighting is applied based on file extension.

When the user selects an option, sdk.client.permission.reply() sends an HTTP POST to /permission/:requestID/reply with { reply: "once" | "always" | "reject", message?: string }.

The server’s reply() function (next.ts:163-233) handles three cases:

Reject (next.ts:179-194):

  • Rejects the specific pending Promise with either CorrectedError (if a message was provided) or RejectedError.
  • Cascading rejection: All other pending permissions for the same session are also rejected. This prevents the agent from stalling on subsequent permissions after the user rejected one.

Once (next.ts:196-198):

  • Resolves the pending Promise. The tool execution resumes.

Always (next.ts:200-231):

  • Adds new rules to s.approved for each pattern in the request’s always array:
    s.approved.push({
    permission: existing.info.permission,
    pattern,
    action: "allow",
    });
  • Resolves the current request’s Promise.
  • Retroactive resolution (next.ts:211-225): Iterates through all pending permissions for the same session. Re-evaluates each against the updated ruleset. If all patterns are now covered by “allow” rules, auto-approves the pending request by resolving its Promise and publishing a permission.replied event.

This retroactive pattern is powerful: if a user approves “always allow edits”, all queued edit permissions resolve immediately without further prompts.

The doom loop detector lives in session/processor.ts:20:

const DOOM_LOOP_THRESHOLD = 3;

After each tool call, the processor checks the last 3 completed tool parts. If all three are the same tool with identical JSON-serialized input, a special permission request is created (processor.ts:144-162):

await PermissionNext.ask({
permission: "doom_loop",
patterns: [value.toolName],
metadata: { tool: value.toolName, input: value.input },
always: [value.toolName],
...
});

The user sees “Continue after repeated failures” with the standard Allow/Reject options. “Allow always” approves that specific tool, preventing further doom loop prompts for it.

When OpenCode operates as an ACP agent (connected to an editor), permissions are proxied through the ACP protocol (acp/agent.ts:14-96):

  1. The agent subscribes to permission.asked events via SSE.
  2. For each permission, it calls connection.requestPermission() with three options: “Allow once”, “Always allow”, “Reject”.
  3. The editor’s ACP client renders its own permission dialog.
  4. The response is forwarded back to the server via sdk.permission.reply().

A sequential promise chain ensures permissions for the same session are handled one at a time, preventing race conditions.


Source: Public documentation at code.claude.com and platform.claude.com (closed source, docs-inferred)

Claude Code’s approval flow builds on the permission system described in Permissions with several unique mechanisms: command injection detection that overrides allowlists, trust verification for new codebases, isolated context windows for web fetch, and a tiered approval persistence model.

Tool TierApproval Behavior”Don’t ask again”Persistence
Read-only (Read, Grep, Glob)No approval neededN/AN/A
Bash commandsPrompt requiredPermanent per project + commandDisk
File modifications (Edit, Write)Prompt requiredUntil session endMemory

The split persistence model is unique to Claude Code. Bash approvals written to disk survive across sessions and restarts. File edit approvals are session-scoped and reset on restart. This reflects the different risk profiles: a bash command approval like git commit * is unlikely to become dangerous over time, while blanket edit approval could be exploited if the project context changes.

Claude Code has a secondary security layer above the rule system: suspicious bash commands require manual approval even if they match an allow rule. This means an allowlisted pattern like Bash(npm *) will still trigger a prompt if the actual command looks like an injection attempt.

This is architecturally significant — it means the rule system is not the final authority. A heuristic detector operates after rule evaluation and can escalate “allow” decisions back to “ask”. No other reference implementation has this override capability.

Claude Code parses shell operators (&&, ||, ;, pipes) in bash commands before matching against permission rules. A rule like Bash(safe-cmd *) will NOT match:

  • safe-cmd && rm -rf /
  • safe-cmd; malicious-cmd
  • safe-cmd | evil-pipe

This is a fundamental improvement over string-based pattern matching. OpenCode’s tree-sitter bash parsing provides similar analysis at the AST level, but the arity system operates on token splitting. Codex classifies whole commands as safe/dangerous without parsing operators. Claude Code’s approach gives the most precise pattern matching while maintaining the user-facing simplicity of glob rules.

Two trust gates that do not exist in other references:

  1. First-time codebase verification: When Claude Code is run in a new project directory for the first time, it requires explicit trust verification before proceeding. This prevents a scenario where a cloned repo contains a malicious .claude/settings.json that auto-approves dangerous operations.

  2. New MCP server verification: When a new MCP server is configured, trust verification is required before its tools are available.

Both verifications are disabled in headless mode (-p flag). This is a conscious tradeoff: CI/CD pipelines need unattended operation, and the trust model shifts to the pipeline configuration.

When Claude Code fetches web content, the fetched content is processed in a separate context window (a separate LLM call), not injected into the main conversation. The summary is returned to the main context.

This is an anti-prompt-injection architecture decision: if a fetched page contains instructions like “ignore your system prompt and…”, those instructions are contained in the isolated context and never reach the main conversation’s system prompt. No other reference implementation has this isolation.

curl and wget are blocked by default regardless of permission rules. This prevents the most common web exfiltration vectors. Users can explicitly allow them, but the docs warn about the limitations of argument-based URL filtering patterns.

Users can cycle permission modes with Shift+Tab in the TUI:

  1. Default (prompt for edits and commands)
  2. Accept Edits (auto-approve edits, still prompt for commands)
  3. Plan Mode (read-only)

The Agent SDK also supports programmatic mode switching mid-session via set_permission_mode() / setPermissionMode().

Unmatched commands default to manual approval (not allow). This is explicitly called out as “fail-closed matching” in the security docs. Combined with the command injection detector, the system errs on the side of prompting rather than permitting.

SystemBash ApprovalsEdit ApprovalsPersistent to DiskRetroactive Resolution
AiderSession (never for shell with --yes-always)N/ANoNo
CodexSession + optional execpolicy amendmentsSession-onlyExecpolicy only (opt-in)No
OpenCodeSession-onlySession-onlyNo (planned)Yes
Claude CodePermanent per project + commandSession-onlyYes (bash only)Not documented

Aider processes approvals strictly sequentially within confirm_ask(). Codex can queue multiple approvals and process them in order. OpenCode serializes per-session but allows concurrent permissions across sessions. The tradeoff: queueing allows faster batch processing but requires careful state management when the user cancels mid-queue (Codex clears the entire queue on Ctrl+C).

OpenCode rejects all pending permissions for a session when any single permission is rejected (next.ts:181-193). This is aggressive but prevents the common pattern where rejecting one operation leads to 5 more stale permission dialogs for related operations. Aider and Codex do not cascade — each rejection is independent.

OpenCode’s “always” retroactive resolution (next.ts:211-225) re-evaluates all pending requests against the new ruleset. This requires that the evaluation function is pure and deterministic — a property that Wildcard.match() provides but a more complex policy engine might not. If evaluation has side effects or depends on external state, retroactive resolution can produce incorrect results.

Aider’s inversion of --yes-always for shell commands is elegant but non-obvious. Users who set --yes-always expect everything to be approved. When shell commands are silently denied by that flag, the agent appears to be broken. The design is correct (auto-approving shell commands in an unsandboxed environment is dangerous), but the failure mode needs clear communication.

Codex treats network approval as distinct from command approval. A command might be pre-approved by execpolicy, but if it makes an outbound connection to a new host, a separate NetworkApprovalContext triggers a network-specific approval dialog with host-specific session caching. This two-axis model (command + network) is more precise but adds complexity to the approval flow.

Both Codex and OpenCode defer approval requests that arrive during active streaming. Codex uses a defer_or_handle gate in chatwidget.rs that queues requests while the answer stream is active. This prevents the approval overlay from interrupting partial markdown rendering, but introduces latency between the agent requesting approval and the user seeing the prompt.

Codex’s ReviewDecision::Abort includes an implicit feedback channel: “tell Codex what to do differently” appears in the option text, and the abort triggers the agent to stop its current approach. OpenCode provides an explicit message field in rejection replies (CorrectedError), allowing the user to type a reason. Aider has no feedback mechanism — rejection simply returns False.

All three tools scope “always allow” rules to the current session. None persist approved rules to disk. OpenCode explicitly comments this out (next.ts:227-230): “we don’t save the permission ruleset to disk yet until there’s UI to manage it.” This means every restart resets all approvals, which is safe but annoying for long-running workflows.


Architecture: Channel-Based Approval with TUI Modal

Section titled “Architecture: Channel-Based Approval with TUI Modal”

OpenOxide should combine Codex’s type-safe approval model with OpenCode’s retroactive resolution. The approval system has three layers:

  1. Protocol layer (openoxide-protocol): Typed approval request/response enums.
  2. State layer (openoxide-permission): Ruleset evaluation, session-scoped caching, retroactive resolution.
  3. TUI layer (openoxide-tui): ratatui modal overlay with keyboard shortcuts.
pub enum ApprovalRequest {
Exec {
call_id: String,
command: Vec<String>,
cwd: PathBuf,
reason: Option<String>,
network_context: Option<NetworkContext>,
proposed_amendment: Option<PolicyAmendment>,
},
Patch {
call_id: String,
changes: HashMap<PathBuf, FileChange>,
reason: Option<String>,
},
McpElicitation {
server_name: String,
request_id: String,
message: String,
},
DoomLoop {
tool_name: String,
input: serde_json::Value,
},
}
pub enum ApprovalResponse {
Approved,
ApprovedForSession { patterns: Vec<String> },
ApprovedWithAmendment(PolicyAmendment),
Rejected { message: Option<String> },
Cancelled,
}

Adopt Claude Code’s layered evaluation (replacing the simpler evaluate-then-prompt flow):

  1. Hooks (PreToolUse) — custom shell commands that can allow, deny, or pass-through. Short-circuits if resolved.
  2. Permission rules (config + session approvals) — deny-first evaluation (deny → allow → ask). Short-circuits on deny or allow.
  3. Permission modebypass_permissions auto-allows, dont_ask auto-denies, accept_edits auto-allows for edit tools, plan auto-denies for write tools.
  4. Approvable callback — tool-specific approval logic via tokio::sync::oneshot, TUI overlay prompt.
  1. Tool execution calls PermissionGate::check(&request).
  2. Gate runs the four-step pipeline above.
  3. If the pipeline reaches step 4 (no earlier resolution), gate sends ApprovalRequest via tokio::sync::oneshot and suspends.
  4. TUI receives request via tokio::sync::mpsc, pushes ApprovalOverlay onto bottom pane.
  5. User selects option → ApprovalResponse sent back via oneshot.
  6. Gate processes response:
    • ApprovedForSession → adds rules to session cache, retroactively resolves queued requests.
    • ApprovedPersistent → writes to .openoxide/approvals.toml for bash commands (Claude Code pattern).
    • Rejected → cascading rejection for all pending requests in same session.
    • Cancelled → same as rejected, no message.

After rule evaluation resolves to Allow, run a secondary heuristic check for suspicious patterns (Claude Code pattern). If the command looks like an injection attempt (shell operator chaining, unusual argument patterns, known-dangerous tool combinations), escalate back to Ask regardless of the allow rule. This override is not configurable — it is a safety floor.

Adopt OpenCode’s pattern: when “always” rules are added, iterate pending requests and auto-resolve those now covered. Use the same Wildcard::matches() evaluation function to ensure consistency.

Follow Codex’s shortcut model: Y (approve), N (reject), A (always), P (prefix rule), Esc (cancel). Add Ctrl+A for fullscreen diff view.

Threshold of 3 identical tool calls (same tool + same serialized input). Create a DoomLoop approval request. “Always” approves that tool for the session.

Unlike Aider’s inline batch grouping, use Codex’s queue model: multiple requests queue and are presented sequentially. This works better in a TUI context where the overlay is a modal.

  • openoxide-protocol: ApprovalRequest, ApprovalResponse, ReviewDecision types.
  • openoxide-permission: PermissionGate, PolicyStack, session cache, retroactive resolution, doom loop detection.
  • openoxide-tui: ApprovalOverlay widget, ListSelectionView integration, keyboard shortcuts.