Skip to content

Permissions

An AI coding agent has broad power: it can read files, write files, execute shell commands, make network requests, and commit to Git. Without a permission system, every one of those actions runs unchecked. The permission problem has three hard sub-problems. First, granularity: a blanket “allow all” or “deny all” is useless for real work, so the system must distinguish between reading a .env file versus reading a .ts file, or running ls versus running rm -rf /. Second, fatigue: prompting the user for every single operation makes the agent unusable, so the system needs batch approval, pattern-based allow-listing, and session memory. Third, safety floor: certain operations (shell execution, writing outside the workspace) are dangerous enough that even an explicit “auto-approve” flag should not silently grant them without the user understanding what they are consenting to.

This page covers the user-facing permission model and approval UX across all three reference implementations. For the OS-level sandbox enforcement (bubblewrap, Seatbelt, seccomp), see Platform Isolation. For the per-tool sandbox policy tiers, see Sandbox Modes. For tool-level prompt interaction details (modal flow, key bindings, batch handling), see Approval Flow.


Repository: references/aider/ | Pinned SHA: b9050e1d

Aider’s permission model is the simplest of the three. There is one universal gate function, a global override flag, and a handful of per-feature toggles. No configuration DSL, no per-tool pattern matching, no persistent rule storage.

Every user-facing permission check in Aider flows through a single method on the InputOutput class.

File: aider/io.py:807-815

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

The parameters control the behavior:

ParameterPurposeExample
questionPrompt text shown to user"Create new file?"
defaultWhat happens if user presses Enter"y" or "n"
subjectThe object being confirmed (path, command, URL)"/src/main.py"
explicit_yes_requiredWhen True, bypasses the --yes-always flag — forces interactive confirmationShell commands
groupBatch multiple confirmations with “All”/“Skip all”Multiple shell commands
allow_neverEnables “Don’t ask again” responseURL additions, file mentions

The prompt is rendered with dynamic options based on context (io.py:831-846):

Run shell command?
python -m pytest tests/
(Y)es/(N)o/(A)ll/(S)kip all/(D)on't ask again [Yes]: _

File: aider/args.py:760-764

The --yes-always flag sets InputOutput.yes = True. When the gate fires, it checks this flag first (io.py:866-871):

if self.yes is True:
res = "n" if explicit_yes_required else "y"
elif self.yes is False:
res = "n"
elif group and group.preference:
res = group.preference
else:
# Interactive prompt to user

The critical design decision: when explicit_yes_required=True and --yes-always is active, the answer is "n" (auto-reject), not "y". This means shell commands cannot be auto-approved — even in full auto mode, shell execution is blocked unless a human explicitly types “yes”. This is the one hardened gate in Aider’s entire permission model.

Every gated operation in Aider and the flag it uses:

OperationFileexplicit_yes_requiredallow_nevergroup
Create new filebase_coder.py:2206NoNoNo
Edit unadded filebase_coder.py:2226NoNoNo
Run shell commandbase_coder.py:2450YesYesYes
Add command output to chatbase_coder.py:2479NoYesNo
Add URL to chatbase_coder.py:971NoYesYes
Add mentioned file to chatbase_coder.py:1770NoYesYes
Fix lint errorsbase_coder.py:1599NoNoNo
Fix test errorsbase_coder.py:1616NoNoNo
Accept architect editsarchitect_coder.py:17NoNoNo
Create git repomain.py:122NoNoNo
Update .gitignoremain.py:191NoNoNo
Proceed past context overflowbase_coder.py:1415NoNoNo

File: aider/io.py:81-89

@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 multiple related operations (e.g., three shell commands from one LLM turn) need confirmation, they are grouped. If the user selects “All”, group.preference = "all" is set and all remaining items in the batch auto-approve. “Skip all” does the inverse. Groups are scoped to a single batch — they don’t persist.

File: aider/io.py:269, 821-825, 902-906

self.never_prompts = set()
question_id = (question, subject)
if question_id in self.never_prompts:
return False # Skip prompt entirely
# When user selects "D":
if res == "d" and allow_never:
self.never_prompts.add(question_id)
return False

The never_prompts set is keyed by (question, subject) tuples. Selecting “Don’t ask again” for ("Add URL to the chat?", "https://example.com") suppresses future prompts for that exact URL. The set lives in memory only — it resets between sessions. Related sets: self.rejected_urls and self.ignore_mentions on base_coder.py:350-358.

Feature Toggle Flags (Not Permission Gates)

Section titled “Feature Toggle Flags (Not Permission Gates)”

Several auto_* flags control whether features run at all, but they are not permission gates — they don’t prompt the user; they silently skip the feature:

FlagDefaultWhat it disables
--no-auto-commitsOffGit commits after LLM edits
--no-auto-lintOffLinting after edits
--no-auto-testOffTest execution after edits
--no-dirty-commitsOffPre-turn commit of user’s changes
--no-auto-accept-architectOffAutomatic acceptance of architect edits

Git commits when auto_commits=True run without any user prompt (base_coder.py:2376). Commit messages are LLM-generated without review (repo.py:326-373). There is no permission gate between the agent deciding to commit and the commit happening.


Repository: references/codex/codex-rs/ | Pinned SHA: 4ab44e2c5

Codex has the most layered permission system: a two-axis policy model (approval policy x sandbox policy), a trait-based per-tool approval interface, a Starlark-based execpolicy DSL, a TUI approval overlay with rich UI, and per-session caching with metrics.

Codex separates when to ask from what is allowed at the OS level.

Axis 1: AskForApproval enumprotocol/src/protocol.rs:359-382

pub enum AskForApproval {
UnlessTrusted, // Only auto-approve is_safe_command() list
OnFailure, // Run in sandbox; ask only if sandbox blocks it (deprecated)
#[default]
OnRequest, // Model-driven: ask when sandbox would restrict
Never, // No approval ever; failures go to model
}

Axis 2: SandboxPolicy enumprotocol/src/protocol.rs:503-564

pub enum SandboxPolicy {
DangerFullAccess, // No restrictions
ReadOnly { access }, // Read-only with optional restrictions
ExternalSandbox { network_access }, // Already in external container
WorkspaceWrite { // Read + write to cwd, /tmp, configured roots
writable_roots,
read_only_access,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
},
}

These two axes combine into a decision matrix implemented in core/src/exec_policy.rs:407-469:

Approval PolicyReadOnlyWorkspaceWriteDangerFullAccessExternalSandbox
UnlessTrustedPromptPromptPromptPrompt
OnFailureAllow (sandbox)Allow (sandbox)AllowAllow
OnRequestPromptPrompt if escalatedAllowAllow
NeverAllow (sandbox)Allow (sandbox)AllowAllow

File: utils/approval-presets/src/lib.rs

Three ready-made presets bundle the two axes:

Preset IDLabelApprovalSandbox
read-onlyRead OnlyOnRequestReadOnly
autoDefaultOnRequestWorkspaceWrite
full-accessFull AccessNeverDangerFullAccess

Users select a preset at startup (or via config), and the two axis values are set accordingly.

File: core/src/exec_policy.rs:161-230

When a command needs execution, create_exec_approval_requirement_for_command() runs:

  1. Parse the command (handle bash -lc "..." shell wrapping)
  2. Check against .rules files — the execpolicy DSL (Starlark-based, stored in ~/.codex/rules/ or .codex/rules/)
  3. If no rule matches, fall through to render_decision_for_unmatched_command() which applies heuristics:
    • If is_known_safe_command() (cat, ls, echo, find, grep, etc.) → Allow
    • If command_might_be_dangerous() (python, bash, git, node, sudo, etc.) → Prompt
    • Otherwise, apply the approval policy matrix above
  4. Generate a proposed ExecPolicyAmendment — a prefix rule to allow future similar commands if approved

File: core/src/tools/sandboxing.rs:192-235

Each tool implements a trait that plugs into the approval system:

pub trait Approvable<Req> {
type ApprovalKey: Hash + Eq + Clone + Debug + Serialize;
fn approval_keys(&self, req: &Req) -> Vec<Self::ApprovalKey>;
fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool;
fn exec_approval_requirement(&self, req: &Req) -> Option<ExecApprovalRequirement>;
fn start_approval_async<'a>(&'a mut self, req: &'a Req, ctx: ApprovalCtx<'a>)
-> BoxFuture<'a, ReviewDecision>;
}

This trait lets each tool define its own approval keys (what to cache), whether to bypass approval (e.g., if already approved for session), and how to request approval asynchronously.

File: core/src/tools/sandboxing.rs:30-107

pub struct ApprovalStore {
map: HashMap<String, ReviewDecision>,
}

The with_cached_approval() helper checks if all approval keys for a request are already approved for the session. If so, it skips the prompt. If not, it fires the approval request, and on ApprovedForSession, caches the keys for the remainder of the session.

Metrics are tracked: Counter: "codex.approval.requested" with tags (tool, approved_status).

File: tui/src/bottom_pane/approval_overlay.rs:40-173

The TUI presents a modal overlay for three types of approval:

pub enum ApprovalRequest {
Exec { id, command, reason, network_approval_context, proposed_execpolicy_amendment },
ApplyPatch { id, reason, cwd, changes },
McpElicitation { server_name, request_id, message },
}

For Exec, the prompt reads: “Would you like to run the following command?” with the command displayed. For ApplyPatch, it reads: “Would you like to make the following edits?” with a diff view. For McpElicitation, it reads: “{server_name} needs your approval.”

pub enum ReviewDecision {
Approved, // One-time
ApprovedForSession, // Cache for session
ApprovedExecpolicyAmendment { amendment }, // Approve + add rule
Denied,
Abort, // Cancel current turn
}

ApprovedExecpolicyAmendment is unique to Codex: it approves the operation and writes a new execpolicy rule so future identical commands auto-approve. This lets the permission set grow organically during a session.

File: core/src/tools/network_approval.rs:22-282

Network access has its own approval flow. When a sandboxed command tries to reach a host and the sandbox blocks it:

  1. NetworkApprovalService checks session_approved_hosts
  2. If not cached, the command approval is augmented with a NetworkApprovalContext { host, protocol }
  3. TUI shows: “Do you want to approve access to “{host}”?”
  4. On approval, the host is cached for the session

File: protocol/src/protocol.rs:749-795

In WorkspaceWrite mode, certain paths are automatically read-only even within writable roots:

let read_only_subpaths = vec![
writable_root.join(".agents"),
writable_root.join(".codex"),
writable_root.join(".git"), // also handles worktrees and submodules
];

The WritableRoot::is_path_writable() method checks both that a path is under a writable root and not under a protected subpath.


Repository: references/opencode/packages/opencode/src/ | Pinned SHA: 7ed449974

OpenCode has the most configurable permission system: a ternary action model, wildcard pattern matching, a command arity dictionary for bash normalization, three-tier permission merging, session-scoped “always” memory with retroactive application, doom loop detection, and a rich TUI with diff previews.

File: permission/next.ts:25-28

export const Action = z.enum(["allow", "deny", "ask"])

Every permission evaluation resolves to one of three states:

  • allow: Tool executes immediately, no prompt
  • deny: Tool is blocked, raises DeniedError with the matching rules
  • ask: TUI prompts the user with once/always/reject options

File: permission/next.ts:30-44

export const Rule = z.object({
permission: z.string(), // Tool name: "edit", "bash", "read"
pattern: z.string(), // Glob pattern: "*.env", "npm run *"
action: Action, // allow/deny/ask
})

Rules are evaluated with last-match-wins semantics (permission/next.ts:239-241):

const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) &&
Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }

If no rule matches, the default is "ask".

File: util/wildcard.ts:4-17

export function match(str: string, pattern: string) {
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) {
escaped = escaped.slice(0, -3) + "( .*)?"
}
return new RegExp("^" + escaped + "$", "s").test(str)
}

A trailing " *" (space + wildcard) is made optional — so "ls *" matches both "ls" and "ls -la". This matters for bash command patterns.

File: permission/arity.ts:25-162

To normalize bash commands into meaningful approval patterns, OpenCode uses a command arity dictionary:

const ARITY: Record<string, number> = {
cat: 1, // "cat" alone
git: 2, // "git checkout"
"git config": 3, // "git config user.name"
npm: 2, // "npm install"
"npm run": 3, // "npm run dev"
cargo: 2, // "cargo build"
docker: 2, // "docker run"
"docker compose": 3, // "docker compose up"
// ... 100+ entries
}

When the bash tool fires, it calls BashArity.prefix(command) to extract the meaningful prefix. For npm run dev --watch, the prefix is ["npm", "run", "dev"], and the “always” pattern becomes "npm run dev *". This way, approving npm run dev once covers npm run dev --watch, npm run dev --port 3000, etc.

File: agent/agent.ts:51-100

Permissions merge from three levels, in order:

  1. Global defaults (hardcoded safety rules):

    {
    "*": "allow",
    doom_loop: "ask",
    external_directory: { "*": "ask" },
    question: "deny",
    read: { "*": "allow", "*.env": "ask", "*.env.*": "ask", "*.env.example": "allow" },
    }
  2. User config (from opencode.json):

    {
    "permission": {
    "bash": { "npm *": "allow", "git *": "allow", "*": "ask" },
    "edit": "ask",
    "external_directory": "deny"
    }
    }
  3. Agent-specific overrides (per agent type):

    • The Explore agent gets restricted permissions: "*": "deny" with only grep, glob, bash, read allowed
    • The Build agent gets question: "allow" added

The merge uses PermissionNext.merge(), and the last rule wins within each tier.

File: permission/next.ts:68-161

When a tool calls ctx.ask():

await ctx.ask({
permission: "edit",
patterns: [filepath],
always: ["*"], // What pattern to remember if "always" selected
metadata: { filepath, diff },
})

The ask() function evaluates the merged ruleset:

  • If any pattern resolves to "deny" → throws DeniedError
  • If any pattern resolves to "ask" → publishes to the TUI via Bus.publish(Event.Asked, ...) and returns a Promise<void> that blocks until the user responds
  • If all patterns resolve to "allow" → returns immediately

File: permission/next.ts:89-90

export const Reply = z.enum(["once", "always", "reject"])
  • once: Allow this specific operation. No memory.
  • always: Allow this pattern permanently for the session. Adds rules to the approved set and retroactively resolves any other pending permissions that now match.
  • reject: Block the operation. The user can optionally provide a text message explaining why, which becomes a CorrectedError sent back to the model as guidance.

Session-Scoped “Always” with Retroactive Application

Section titled “Session-Scoped “Always” with Retroactive Application”

File: permission/next.ts:200-225

When the user selects “always”:

for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
// Retroactively resolve other pending permissions
for (const [id, pending] of Object.entries(s.pending)) {
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (ok) {
delete s.pending[id]
pending.resolve()
}
}

This retroactive resolution is important: if the agent fires three edit calls in rapid succession and the user approves the first with “always”, the second and third resolve automatically without prompting.

Approved patterns live in memory only. The code has a TODO comment (permission/next.ts:227-230) noting that persistent storage to SQLite is planned but not yet implemented.

File: session/processor.ts:20, 152-176

When the agent calls the same tool with identical inputs three times in a row (DOOM_LOOP_THRESHOLD = 3), the processor fires a special permission request:

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

The doom loop permission is configured as "ask" by default. If rejected, the session breaks (unless experimental.continue_loop_on_deny is set).

File: cli/cmd/tui/routes/session/permission.tsx:119-299

The TUI renders tool-specific approval prompts:

  • Edit operations: Show a unified or split diff of the proposed changes
  • Bash commands: Show the command with $ prefix and description
  • Doom loops: Show “Continue after repeated failures” with a loop icon
  • External directories: Show the directory path being accessed

Navigation: Left/Right arrows (or h/l) to select option, Enter to confirm, Escape to reject, Ctrl+F for fullscreen diff view.

File: permission/next.ts:245-257

Tools that are "deny": "*" in the ruleset are removed from the LLM’s tool list entirely at setup time:

export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
if (rule?.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}

This prevents the model from even attempting to call a forbidden tool, saving a round trip.


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

Claude Code has the most structured permission system of any reference: a tiered tool classification, deny-first rule evaluation, five permission modes, a four-step evaluation pipeline, five-level settings precedence, managed enterprise controls, and persistent bash command approvals.

Every built-in tool falls into one of three tiers:

Tool TierExamplesApproval Required”Don’t ask again” Scope
Read-onlyFile reads, Grep, GlobNoN/A
Bash commandsShell executionYesPermanent per project directory + command
File modificationEdit, WriteYesUntil session end

The critical distinction: bash command approvals persist to disk permanently for the project+command pair, while file modification approvals are session-scoped. This is unique among all reference implementations. Aider, Codex, and OpenCode all scope approvals to the current session (Codex’s execpolicy amendments can optionally persist, but it is per-approval opt-in).

Rule Evaluation Order: deny -> ask -> allow

Section titled “Rule Evaluation Order: deny -> ask -> allow”

Rules are evaluated in deny-first order:

  1. Check deny rules first (block regardless of other rules)
  2. Check allow rules (permit if matched)
  3. Check ask rules (prompt for approval)
  4. First matching rule at each tier wins

This is a significant departure from OpenCode’s last-match-wins semantics and Codex’s two-axis matrix. The deny-first approach means deny rules always take precedence regardless of where they appear in the configuration, which is more intuitive for security but less flexible for complex override patterns.

Example configuration:

{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git commit *)"
],
"deny": [
"Bash(git push *)"
]
}
}

Here, git push is always denied even though git * patterns could theoretically match via the allow rules.

Five modes, settable at startup via --permission-mode or dynamically mid-session:

ModeDescription
defaultStandard: prompts on first use of each tool
acceptEditsAuto-accept file edits and filesystem commands (mkdir, touch, rm, mv, cp)
planRead-only: Claude analyzes but cannot modify or execute
dontAskAuto-deny tools unless pre-approved via rules
bypassPermissionsSkip all permission checks (requires isolated environment)

dontAsk vs bypassPermissions distinction: dontAsk silently denies any tool not explicitly allowed by rules — it is a lockdown mode where only pre-approved operations proceed. bypassPermissions skips all checks entirely — everything is auto-approved. The difference is the direction of the default: dontAsk defaults to deny, bypassPermissions defaults to allow.

Subagent inheritance: bypassPermissions propagates to ALL subagents unconditionally and cannot be overridden per-subagent. This is deliberate but dangerous — subagents may have different system prompts and less constrained behavior. Managed settings can disable this mode entirely.

Dynamic switching: The Agent SDK supports changing permission mode mid-session via set_permission_mode(). This enables patterns like “start restrictive, loosen after reviewing the agent’s approach.”

Rules follow the format Tool or Tool(specifier):

Bash rules with glob patterns:

  • Bash or Bash(*) — match all bash commands
  • Bash(npm run build) — exact match
  • Bash(npm run *) — prefix match with word boundary (space before *)
  • Bash(npm*) — prefix match without word boundary
  • Bash(* --version) — suffix match
  • Bash(git * main) — middle wildcard

Word boundary semantics: Space before * enforces a word boundary. Bash(ls *) matches ls -la but NOT lsof. Bash(ls*) without space matches both. This is more nuanced than OpenCode’s arity-based prefix system.

Shell operator awareness: Claude Code parses shell operators (&&, ||, ;, pipes). A rule like Bash(safe-cmd *) will NOT match safe-cmd && evil-cmd. This is a significant security improvement over pattern-matching systems that operate on raw command strings.

Read/Edit rules follow the gitignore specification with four path types:

PatternMeaningExample
//pathAbsolute from filesystem rootRead(//Users/alice/secrets/**)
~/pathHome directory relativeRead(~/Documents/*.pdf)
/pathRelative to settings fileEdit(/src/**/*.ts)
path or ./pathRelative to CWDRead(*.env)

Uses gitignore * (single directory) vs ** (recursive across directories) semantics.

MCP rules: mcp__servername__toolname or mcp__servername__* for all tools from a server.

Task (subagent) rules: Task(AgentName) to control which subagents can be used. Adding Task(Explore) to the deny array disables the Explore agent.

The Agent SDK reveals the full evaluation sequence:

  1. Hooks (PreToolUse) — custom shell commands that can allow, deny, or pass-through
  2. Permission rules (settings.json) — deny -> allow -> ask evaluation
  3. Permission mode — bypassPermissions, acceptEdits, dontAsk, etc.
  4. canUseTool callback — runtime approval from the host application

This is the most layered evaluation of any reference. Each step can short-circuit: if hooks allow, rules are skipped. If rules deny, mode is skipped. The callback is the final fallback when no earlier step resolved the decision.

From highest to lowest priority:

  1. Managed settings — system-wide, admin-deployed, cannot be overridden
  2. CLI arguments--permission-mode, --allowedTools, etc.
  3. Local project.claude/local-settings.json (gitignored, per-developer)
  4. Shared project.claude/settings.json (checked into VCS)
  5. User settings~/.claude/settings.json

If a permission is allowed in user settings but denied in project settings, the project setting wins.

System-wide policy files deployed by IT administrators to non-user paths:

PlatformPath
macOS/Library/Application Support/ClaudeCode/managed-settings.json
Linux/WSL/etc/claude-code/managed-settings.json
WindowsC:\Program Files\ClaudeCode\managed-settings.json

Settings only effective in managed context:

SettingPurpose
disableBypassPermissionsModePrevent bypassPermissions mode and --dangerously-skip-permissions
allowManagedPermissionRulesOnlyBlock user/project permission rules; only managed rules apply
allowManagedHooksOnlyBlock user/project/plugin hooks; only managed + SDK hooks allowed
strictKnownMarketplacesControl which plugin marketplaces users can add

No other reference implementation has anything equivalent to this enterprise control surface.


Approval fatigue is the primary failure mode. If the agent needs to edit 15 files in a single turn and each one prompts, the user will either switch to auto-approve (defeating the purpose) or abandon the session. Aider’s batch groups and OpenCode’s retroactive “always” resolution both address this, but neither is perfect — Aider’s groups only work within a single batch, and OpenCode’s pattern memory resets between sessions.

Shell commands are the highest-risk gate. All three implementations recognize this: Aider uses explicit_yes_required, Codex uses the exec policy DSL with dangerous-command heuristics, and OpenCode uses tree-sitter parsing plus the arity dictionary. The hard lesson from Aider’s design is that --yes-always auto-rejecting shell commands (returning “no” instead of “yes”) is surprising to users who expect it to approve everything.

Pattern specificity is tricky. OpenCode’s "npm run *" pattern looks clean, but edge cases abound: npm run "build && rm -rf /" matches "npm run *". Codex avoids this by classifying entire commands as safe or dangerous rather than pattern-matching substrings. OpenCode mitigates it partially through tree-sitter bash parsing, but the arity system operates on token splitting, not AST analysis.

Network approval is under-covered. Only Codex has explicit network approval with per-host caching. Aider has no network gating at all. OpenCode has no OS-level network restriction. For agents that can fetch URLs or install packages, network access is a significant attack surface.

Persistent approval is deliberately avoided (except by Claude Code). Aider and OpenCode reset approval state between sessions. Codex’s execpolicy amendments can persist to disk, but it is opt-in per approval. Claude Code breaks this pattern by persisting bash command approvals permanently per project+command. The risk of persisting approvals is that a compromised session could write permissive rules that affect future sessions — Claude Code accepts this risk for bash commands specifically, betting that the convenience outweighs the threat model for commands a user has explicitly approved.

Rule evaluation order has security implications. OpenCode’s last-match-wins allows later rules to override earlier denials, which is powerful for layered configs but means a misconfigured later rule can silently permit dangerous operations. Claude Code’s deny-first approach (deny -> allow -> ask) guarantees that deny rules always win regardless of ordering, which is more intuitive for security. Codex sidesteps the issue entirely with its two-axis matrix. The choice between evaluation orders affects how users reason about their permission configs.

Shell operator injection defeats naive pattern matching. A rule like Bash(safe-cmd *) looks safe, but safe-cmd && rm -rf / matches naive glob patterns. Claude Code addresses this by parsing shell operators — the rule won’t match commands joined by &&, ||, ;, or pipes. OpenCode partially mitigates via tree-sitter bash parsing but applies arity at the token level, not the AST level. This is a real attack vector for agents with user-facing pattern rules.

Enterprise lockdown requires a separate settings layer. Claude Code’s managed settings (system-wide, admin-deployed, cannot be overridden) solve a real enterprise problem: ensuring that developer-configured rules cannot weaken organizational policy. No other reference implementation has this. The allowManagedPermissionRulesOnly setting goes further by preventing ALL user/project rules, ensuring only IT-approved rules apply.


OpenOxide will implement a rule-based permission system combining the best patterns from all three references:

Core types (Rust):

pub enum PermissionAction {
Allow,
Deny,
Ask,
}
pub struct PermissionRule {
pub permission: String, // Tool name or glob
pub pattern: String, // File path or command glob
pub action: PermissionAction,
}
pub type Ruleset = Vec<PermissionRule>;

Evaluation: Deny-first semantics (following Claude Code). Rules are organized into three arrays: deny, allow, ask. Deny rules are checked first, then allow, then ask. First match within each tier wins. Default to Ask when no rule matches. This is a departure from the original OpenCode-inspired last-match-wins plan — the deny-first approach is more intuitive for security (deny always wins regardless of ordering) and aligns with how users expect permission systems to work.

  • openoxide-permission: Core rule evaluation, pattern matching, approval store. Depends on globset for glob matching (not regex conversion like OpenCode — globset handles edge cases better).
  • openoxide-permission-tui: ratatui-based approval overlay with diff rendering (following Codex’s overlay pattern). Keybindings: y/n/a (always)/s (skip all)/Esc (reject with message).
  1. Deny-first evaluation (Claude Code pattern): Rules organized into deny, allow, ask arrays. Deny rules checked first, then allow, then ask. This replaces the earlier plan for last-match-wins (OpenCode pattern). Deny-first is more intuitive for security and matches how users reason about access control.

  2. Five-level settings precedence (Claude Code pattern): Managed (system-wide) → CLI arguments → local project (gitignored) → shared project (VCS) → user config. Use Vec<SettingsLayer> with a merge that respects precedence and deny-first evaluation within each layer.

  3. Four-step evaluation pipeline (Claude Code pattern): Hooks → Permission rules → Permission mode → Approvable callback. Each step can short-circuit. This replaces the simpler evaluate-then-prompt flow from the OpenCode-inspired design.

  4. Session-scoped approval memory with retroactive resolution (OpenCode pattern) for file modifications. Persistent approval storage for bash commands (Claude Code pattern), scoped to project directory + command pattern. Store persistent approvals in .openoxide/approvals.toml.

  5. Command arity system (OpenCode pattern) for bash normalization, plus shell operator awareness (Claude Code pattern). Parse &&, ||, ;, and pipes before pattern matching so safe-cmd && evil-cmd does not match safe-cmd * rules.

  6. Dangerous command classification (Codex pattern) as a fallback. If no rule matches a shell command, check against a known-dangerous list (interpreters, package managers, git) and require explicit approval. Additionally, implement a command injection detector (Claude Code pattern) that overrides allow rules for suspicious commands.

  7. Doom loop detection (OpenCode pattern). Track the last N tool calls with their inputs in a ring buffer. If the same (tool, input_hash) appears 3 times consecutively, fire a "doom_loop" permission request.

  8. Tool pre-filtering (OpenCode pattern). Before sending the tool list to the LLM, remove tools that are Deny("*") in the merged ruleset.

  9. Permission modes (Claude Code pattern): Support default, accept_edits, plan, dont_ask, and bypass_permissions modes. bypass_permissions inherits to all subagents. Support dynamic mode switching mid-session.

  10. Managed settings (Claude Code pattern): Support a system-wide config at /etc/openoxide/managed.toml (Linux) that cannot be overridden. Support disable_bypass_permissions, allow_managed_rules_only, and allow_managed_hooks_only flags.

  11. Gitignore-compatible path patterns (Claude Code pattern) for Read/Edit rules: support //path (absolute), ~/path (home), /path (relative to config), and path (relative to CWD).

pub trait Approvable {
fn permission_name(&self) -> &str;
fn patterns(&self, args: &serde_json::Value) -> Vec<String>;
fn always_patterns(&self, args: &serde_json::Value) -> Vec<String>;
fn metadata(&self, args: &serde_json::Value) -> serde_json::Value;
}

Each tool implements Approvable to declare what permission it needs and what patterns to remember. The approval system calls permission_name() and patterns() to evaluate the ruleset, and always_patterns() to determine what to cache on “always” approval.