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.
Feature Definition
Section titled “Feature Definition”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 Implementation
Section titled “Aider Implementation”Aider’s approval flow centers on a single function: confirm_ask() in aider/io.py:807.
The confirm_ask() Signature
Section titled “The confirm_ask() Signature”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: WhenTrue, the--yes-alwaysauto-approve flag defaults to"n"instead of"y". Used for destructive operations — shell commands use this, file creation does not.group: AConfirmGroupinstance 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.
Terminal Rendering
Section titled “Terminal Rendering”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.
Response Parsing
Section titled “Response Parsing”- Empty input → use
default(io.py:889-891) - Ctrl+D (EOF) → use
default(io.py:884-887) - First character extracted after lowercasing (
io.py:900) - Prefix matching validates against
["yes", "no", "skip", "all", "don't"](io.py:893) - Invalid input → error message and re-prompt (
io.py:897-898)
The --yes-always Short-Circuit
Section titled “The --yes-always Short-Circuit”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.
ConfirmGroup: Batch Approval
Section titled “ConfirmGroup: Batch Approval”The ConfirmGroup dataclass (io.py:81-89) enables batch processing for collections of similar items:
@dataclassclass ConfirmGroup: preference: str = None show_group: bool = True
def __init__(self, items=None): if items is not None: self.show_group = len(items) > 1When 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:
- Shell commands (
base_coder.py:2439):ConfirmGroup(set(self.shell_commands))— groups multiple commands from a single model response. - URL mentions (
base_coder.py:972):ConfirmGroup(urls)— groups auto-detected URLs. - File mentions (
base_coder.py:1770):ConfirmGroup(new_mentions)— groups auto-detected file references.
Session-Level “Never” Memory
Section titled “Session-Level “Never” Memory”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.
Call Site Inventory
Section titled “Call Site Inventory”| Location | Question | Subject | explicit_yes | group | allow_never |
|---|---|---|---|---|---|
base_coder.py:2456 | ”Run shell command(s)?” | \n.join(commands) | Yes | Yes | Yes |
base_coder.py:2207 | ”Create new file?“ | path | No | No | No |
base_coder.py:2226 | ”Allow edits to unadded file?“ | path | No | No | No |
base_coder.py:972 | ”Add URL to chat?“ | url | No | Yes | Yes |
base_coder.py:1770 | ”Add file to chat?“ | rel_fname | No | Yes | Yes |
base_coder.py:2479 | ”Add command output to chat?” | None | No | No | Yes |
architect_coder.py:17 | ”Edit the files?” | None | No | No | No |
commands.py:389 | ”Fix lint errors in {fname}?” | None | No | No | No |
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 Implementation
Section titled “Codex Implementation”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
Three Approval Request Types
Section titled “Three Approval Request Types”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,}The ApprovalOverlay Widget
Section titled “The ApprovalOverlay Widget”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 theListSelectionViewwidget for rendering selectable options with keyboard navigation.current_complete: Guards against double-processing the same selection.done: Whentrue, the overlay is popped from the view stack.
Option Sets Per Approval Type
Section titled “Option Sets Per Approval Type”Three functions (approval_overlay.rs:467-571) define the options for each approval type:
Exec (normal command):
- “Yes, proceed” —
ReviewDecision::Approved— shortcut: Y - “Yes, and don’t ask again for commands that start with
<prefix>” —ReviewDecision::ApprovedExecpolicyAmendment— shortcut: P (only shown if aproposed_execpolicy_amendmentis provided, and the rendered prefix is single-line) - “No, and tell Codex what to do differently” —
ReviewDecision::Abort— shortcut: Esc or N
Exec (network-restricted):
- “Yes, just this once” —
ReviewDecision::Approved— shortcut: Y - “Yes, and allow this host for this session” —
ReviewDecision::ApprovedForSession— shortcut: A - “No, and tell Codex what to do differently” —
ReviewDecision::Abort— shortcut: Esc or N
Patch (file writes):
- “Yes, proceed” —
ReviewDecision::Approved— shortcut: Y - “Yes, and don’t ask again for these files” —
ReviewDecision::ApprovedForSession— shortcut: A - “No, and tell Codex what to do differently” —
ReviewDecision::Abort— shortcut: Esc or N
MCP Elicitation:
- “Yes, provide the requested info” —
ElicitationAction::Accept— shortcut: Y - “No, but continue without it” —
ElicitationAction::Decline— shortcut: N - “Cancel this request” —
ElicitationAction::Cancel— shortcut: Esc or C
Visual Rendering
Section titled “Visual Rendering”The overlay header is built in ApprovalRequestState::from() (approval_overlay.rs:352-425):
- Exec commands: Displayed with
$prefix and bash syntax highlighting viahighlight_bash_to_lines(). If areasonis provided, it appears above the command as “Reason: reason” in italics. - Patches: Rendered as a
DiffSummaryshowing 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”.
Input Handling
Section titled “Input Handling”Three input paths (approval_overlay.rs:246-286):
- Direct shortcuts (
try_handle_shortcut): Alphabetic keys (Y, N, P, A, C) trigger selection immediately by matching against each option’s shortcut list. - Ctrl+A: Opens a full-screen popup for the current approval request (
approval_overlay.rs:250-260), useful for reviewing long diffs. - 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.
Decision Routing
Section titled “Decision Routing”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).
Queue Management
Section titled “Queue Management”The request lifecycle from agent to overlay:
- Agent emits event → arrives at
chatwidget.rs:on_exec_approval_request()(~line 1810). - Defer-or-handle gate: If the TUI is still streaming LLM output, the request is queued for later. If idle, it proceeds immediately.
- Convert to UI request:
handle_exec_approval_now()(~line 2387) flushes any in-progress streaming, creates aNotification, and builds anApprovalRequestenum variant. - 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
ApprovalOverlaymodal and pushes it onto the view stack.
- Enqueues into an existing overlay via
- 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. - User decides →
apply_selection()→advance_queue()either pops the next request or marks the overlay as done.
ReviewDecision Enum
Section titled “ReviewDecision Enum”The five decision variants:
| Variant | Meaning | Scope |
|---|---|---|
Approved | Execute/apply once | This request only |
ApprovedForSession | Execute/apply and cache for session | Session-wide |
ApprovedExecpolicyAmendment | Approve and save prefix rule | Persistent (written to execpolicy) |
Denied | Reject silently | This request only |
Abort | Reject with user feedback | This request + clears queue |
OpenCode Implementation
Section titled “OpenCode Implementation”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.
Server-Side: Permission State
Section titled “Server-Side: Permission State”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.
The Ask Flow
Section titled “The Ask Flow”When a tool needs permission, it calls PermissionNext.ask() (next.ts:131-161):
- For each pattern in the request, evaluate against the combined ruleset (config rules + session-approved rules).
- If
deny→ throwDeniedErrorimmediately. - If
allow→ continue to next pattern. - If
ask→ create a Promise, store it inpending, and publish apermission.askedbus 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.
The Request Schema
Section titled “The Request Schema”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 *").
TUI Event Subscription
Section titled “TUI Event Subscription”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).
The Permission Dialog Component
Section titled “The Permission Dialog Component”PermissionPrompt (session/permission.tsx:119-298) is a Solid.js component with three stages:
"permission"— Initial display showing the request details and three options."always"— Confirmation dialog explaining that “Allow always” lasts until restart."reject"— Text input for an optional rejection reason.
Keyboard handling (permission.tsx:393-425):
| Key | Action |
|---|---|
| Left / H | Navigate to previous option |
| Right / L | Navigate to next option |
| Enter | Select current option |
| Escape | Trigger rejection |
| Ctrl+F | Toggle 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"
Permission Type Rendering
Section titled “Permission Type Rendering”Each permission type has a custom renderer (permission.tsx:201-264):
| Permission | Icon | Display |
|---|---|---|
edit | → | File path + split/unified diff view |
read | ← | “Read {filepath}“ |
glob | ◎ | Glob pattern |
grep | ◎ | Grep pattern |
bash | $ | Command description + full command text |
task | ⊕ | Subagent type + description |
webfetch | ↗ | URL |
websearch | ↗ | Search 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.
Reply Processing
Section titled “Reply Processing”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) orRejectedError. - 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.approvedfor each pattern in the request’salwaysarray: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 apermission.repliedevent.
This retroactive pattern is powerful: if a user approves “always allow edits”, all queued edit permissions resolve immediately without further prompts.
Doom Loop Detection
Section titled “Doom Loop Detection”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.
Permission in ACP Mode
Section titled “Permission in ACP Mode”When OpenCode operates as an ACP agent (connected to an editor), permissions are proxied through the ACP protocol (acp/agent.ts:14-96):
- The agent subscribes to
permission.askedevents via SSE. - For each permission, it calls
connection.requestPermission()with three options: “Allow once”, “Always allow”, “Reject”. - The editor’s ACP client renders its own permission dialog.
- 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.
Claude Code Implementation
Section titled “Claude Code Implementation”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.
Approval Tiers by Tool Type
Section titled “Approval Tiers by Tool Type”| Tool Tier | Approval Behavior | ”Don’t ask again” | Persistence |
|---|---|---|---|
| Read-only (Read, Grep, Glob) | No approval needed | N/A | N/A |
| Bash commands | Prompt required | Permanent per project + command | Disk |
| File modifications (Edit, Write) | Prompt required | Until session end | Memory |
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.
Command Injection Detection Override
Section titled “Command Injection Detection Override”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.
Shell Operator Awareness
Section titled “Shell Operator Awareness”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-cmdsafe-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.
Trust Verification
Section titled “Trust Verification”Two trust gates that do not exist in other references:
-
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.jsonthat auto-approves dangerous operations. -
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.
Isolated Context Windows for Web Fetch
Section titled “Isolated Context Windows for Web Fetch”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.
Command Blocklist
Section titled “Command Blocklist”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.
Permission Mode Switching
Section titled “Permission Mode Switching”Users can cycle permission modes with Shift+Tab in the TUI:
- Default (prompt for edits and commands)
- Accept Edits (auto-approve edits, still prompt for commands)
- Plan Mode (read-only)
The Agent SDK also supports programmatic mode switching mid-session via set_permission_mode() / setPermissionMode().
Fail-Closed Design
Section titled “Fail-Closed Design”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.
Approval Persistence Comparison
Section titled “Approval Persistence Comparison”| System | Bash Approvals | Edit Approvals | Persistent to Disk | Retroactive Resolution |
|---|---|---|---|---|
| Aider | Session (never for shell with --yes-always) | N/A | No | No |
| Codex | Session + optional execpolicy amendments | Session-only | Execpolicy only (opt-in) | No |
| OpenCode | Session-only | Session-only | No (planned) | Yes |
| Claude Code | Permanent per project + command | Session-only | Yes (bash only) | Not documented |
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Sequential vs. Concurrent Approvals
Section titled “Sequential vs. Concurrent Approvals”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).
Cascading Rejection Scope
Section titled “Cascading Rejection Scope”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.
Retroactive Resolution Is Subtle
Section titled “Retroactive Resolution Is Subtle”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.
The explicit_yes_required Design
Section titled “The explicit_yes_required Design”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.
Network Approval Is a Special Axis
Section titled “Network Approval Is a Special Axis”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.
Approval During Streaming
Section titled “Approval During Streaming”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.
Rejection Feedback Channel
Section titled “Rejection Feedback Channel”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.
Session Scope Without Persistence
Section titled “Session Scope Without Persistence”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.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”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:
- Protocol layer (
openoxide-protocol): Typed approval request/response enums. - State layer (
openoxide-permission): Ruleset evaluation, session-scoped caching, retroactive resolution. - TUI layer (
openoxide-tui): ratatui modal overlay with keyboard shortcuts.
Approval Request Enum
Section titled “Approval Request Enum”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, },}Response Enum
Section titled “Response Enum”pub enum ApprovalResponse { Approved, ApprovedForSession { patterns: Vec<String> }, ApprovedWithAmendment(PolicyAmendment), Rejected { message: Option<String> }, Cancelled,}Four-Step Evaluation Pipeline
Section titled “Four-Step Evaluation Pipeline”Adopt Claude Code’s layered evaluation (replacing the simpler evaluate-then-prompt flow):
- Hooks (PreToolUse) — custom shell commands that can allow, deny, or pass-through. Short-circuits if resolved.
- Permission rules (config + session approvals) — deny-first evaluation (deny → allow → ask). Short-circuits on deny or allow.
- Permission mode —
bypass_permissionsauto-allows,dont_askauto-denies,accept_editsauto-allows for edit tools,planauto-denies for write tools. - Approvable callback — tool-specific approval logic via
tokio::sync::oneshot, TUI overlay prompt.
Approval Flow (Step 4 Detail)
Section titled “Approval Flow (Step 4 Detail)”- Tool execution calls
PermissionGate::check(&request). - Gate runs the four-step pipeline above.
- If the pipeline reaches step 4 (no earlier resolution), gate sends
ApprovalRequestviatokio::sync::oneshotand suspends. - TUI receives request via
tokio::sync::mpsc, pushesApprovalOverlayonto bottom pane. - User selects option →
ApprovalResponsesent back via oneshot. - Gate processes response:
ApprovedForSession→ adds rules to session cache, retroactively resolves queued requests.ApprovedPersistent→ writes to.openoxide/approvals.tomlfor bash commands (Claude Code pattern).Rejected→ cascading rejection for all pending requests in same session.Cancelled→ same as rejected, no message.
Command Injection Override
Section titled “Command Injection Override”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.
Retroactive Resolution
Section titled “Retroactive Resolution”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.
Key Bindings
Section titled “Key Bindings”Follow Codex’s shortcut model: Y (approve), N (reject), A (always), P (prefix rule), Esc (cancel). Add Ctrl+A for fullscreen diff view.
Doom Loop Detection
Section titled “Doom Loop Detection”Threshold of 3 identical tool calls (same tool + same serialized input). Create a DoomLoop approval request. “Always” approves that tool for the session.
Batch Approval
Section titled “Batch Approval”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.
Crates
Section titled “Crates”openoxide-protocol:ApprovalRequest,ApprovalResponse,ReviewDecisiontypes.openoxide-permission:PermissionGate,PolicyStack, session cache, retroactive resolution, doom loop detection.openoxide-tui:ApprovalOverlaywidget,ListSelectionViewintegration, keyboard shortcuts.