Skip to content

Permission Source Code Analysis

Module Overview

The Permission module is OpenCode’s security gatekeeper. Whenever the Agent attempts to invoke a tool — read a file, write a file, execute a Bash command — the Permission module intervenes and makes a three-level decision: auto-allow, block-and-deny, or pause and ask the user. Finding the balance between “full autonomy” and “safe and controllable” is the core design goal of the Permission module.

Core design choices:

  • Effect Deferred async model: ask/reply uses Effect.Deferred for suspend/resume, with native support for cancellation and timeouts
  • last-match-wins rule evaluation: later rules in the configuration override earlier ones, better matching the intuition of “declare general rules first, then declare exceptions”
  • Wildcard pattern matching: * maps to .*, ? maps to ., with path separator normalization before matching
  • Bus event broadcasting: Event.Asked / Event.Replied are broadcast via Bus, allowing both CLI and TUI to subscribe
  • Project-level persistence: user always decisions are written to SQLite, bound to project_id, preventing cross-project permission leakage
  • Cascade rejection and batch approval: rejecting one request cascades to reject all pending requests in the same session; always approval automatically checks and approves other satisfied pending requests

Permission is implemented based on Effect.ts’s ServiceMap.Service architecture, completing the asynchronous “ask-reply” flow through the Bus event system, and persisting user authorization decisions via SQLite.

Key Files

File PathLinesResponsibility
src/permission/index.ts~326Module main entry: Service definition, ask/reply flow, fromConfig parsing, expand path expansion, cascade logic
src/permission/evaluate.ts~15Pure function: last-match-wins rule evaluation, findLast semantics
src/permission/schema.ts~18PermissionID type definition
src/permission/arity.ts~163BashArity dictionary: argument count mapping for Bash commands
src/session/session.sql.tsPermissionTable definition, SQLite storage for Ruleset
src/util/wildcard.tsWildcard.match / Wildcard.all, wildcard pattern matching engine

Type System

Action — Permission Action Tri-state

The Permission module defines three action levels using a Zod enum:

const Action = z.enum(["allow", "deny", "ask"]).meta({ ref: "PermissionAction" });
  • allow: auto-allow, the Agent does not wait, the tool executes directly
  • ask: pause execution, asynchronously wait for explicit user approval via Deferred
  • deny: reject directly, throw DeniedError, the Agent receives an error signal

Note: it is ask not confirm. ask emphasizes the “questioning” semantics, corresponding to the Reply type below.

Reply — User Response Tri-state

When the user responds to an inquiry, the Reply enum is used:

const Reply = z.enum(["once", "always", "reject"]);
  • once: allow only this time, similar operations will still require asking next time
  • always: remember the decision, write to approved: Ruleset and persist to SQLite, auto-allow subsequently
  • reject: reject this time, the Agent receives RejectedError

Rule — Single Permission Rule

A permission rule consists of three dimensions:

const Rule = z.object({
  permission: z.string(),  // Tool/operation name, e.g. "read", "bash", "mcp.github.create_issue"
  pattern: z.string(),     // File path or command pattern, e.g. "src/**", ".env", "rm *"
  action: Action,          // allow / deny / ask
});
  • permission: tool name. Built-in tools include read, edit, bash, glob, grep, etc.; MCP tools use server.tool format (e.g. github.create_issue)
  • pattern: matching scope. Can be a file path ("src/**"), command pattern ("rm *"), or wildcard ("*" matches everything)
  • action: the action to take when the rule matches

Ruleset — Rule Collection

Ruleset is a type alias for Rule[]. During permission evaluation, all rules are matched sequentially, and the last matching rule takes effect (last-match-wins).

type Ruleset = Rule[]

Rulesets come from two sources:

  1. configRules: initial rules loaded from the Config file
  2. approvedRules: rules accumulated at runtime through user “always” decisions

At evaluation time, both are merged into a single array: evaluate(permission, pattern, configRules, approvedRules).

PendingEntry — Async Pending Request

type PendingEntry = {
  deferred: Effect.Deferred<...>   // Async suspension point
  request: PermissionRequest       // Original request information
}

When evaluate() returns ask, Permission creates a PendingEntry and stores it in the pending Map. deferred is Effect’s async primitive — similar to a Promise but supports external resolve/fail.

EDIT_TOOLS — Edit Tool Collection

const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]

This is a built-in constant used to distinguish read operations from write operations. Certain logic (such as default permission policies) determines the initial action based on whether a tool belongs to EDIT_TOOLS.

Core Flow

Rule Evaluation: last-match-wins (evaluate.ts)

evaluate() is the decision-making core of the entire Permission module — a pure, side-effect-free function:

import { Wildcard } from "@/util/wildcard"

type Rule = {
  permission: string
  pattern: string
  action: "allow" | "deny" | "ask"
}

export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
  const rules = rulesets.flat()
  const match = rules.findLast(
    (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
  )
  return match ?? { action: "ask", permission, pattern: "*" }
}

Key design points:

  1. Multi-Ruleset flattening: accepts variadic ...rulesets, typically [configRules, approvedRules], flattened into a single array
  2. Dual matching: each rule requires both permission and pattern dimensions to match
  3. findLast semantics: the last matching rule wins. This allows a “deny all first, then allow specific paths” declaration pattern
  4. Default to ask: when no rule matches, returns { action: "ask" } — safety first, better to ask once more

Wildcard Matching Engine (wildcard.ts)

Wildcard.match() converts glob patterns to regular expressions for matching:

import { sortBy, pipe } from "remeda"

export namespace Wildcard {
  export function match(str: string, pattern: string) {
    // Path separator normalization: Windows backslash → forward slash
    if (str) str = str.replaceAll("\\", "/")
    if (pattern) pattern = pattern.replaceAll("\\", "/")

    // Glob to regex:
    // 1. Escape regex special characters (. + ^ $ { } ( ) | [ ] \)
    let escaped = pattern
      .replace(/[.+^${}()|[\]\\]/g, "\\$&")
      .replace(/\*/g, ".*")    // * → .* (match any character sequence)
      .replace(/\?/g, ".")     // ? → .  (match any single character)

    // Handle trailing " .*" pattern, making it optional
    // Example: "src" matches both "src" and "src/xxx"
    if (escaped.endsWith(" .*")) {
      escaped = escaped.slice(0, -3) + "( .*)?"
    }

    // Platform adaptation: Windows is case-insensitive
    const flags = process.platform === "win32" ? "si" : "s"
    return new RegExp("^" + escaped + "$", flags).test(str)
  }
}

Matching rules in detail:

Glob PatternRegular ExpressionMatch Examples
*^.*$Matches all strings
src/**^src/.*$Matches all paths under src/
.env^\.env$Exact match for .env
*.ts^.*\.ts$Matches all .ts files
rm *^rm .*$Matches all commands starting with rm

Path normalization: Windows \ is replaced with / before matching, ensuring cross-platform consistency. The s flag allows . to also match newlines.

fromConfig — Configuration Parsing

fromConfig() parses permission configuration from Config into a Ruleset, supporting two formats:

// Format 1: Shorthand format (only declare action)
const config1 = {
  permission: {
    bash: "allow",              // Allow all bash operations
    edit: "deny",               // Deny all edit operations
  }
}
// Parsed as:
// [
//   { permission: "bash", pattern: "*", action: "allow" },
//   { permission: "edit", pattern: "*", action: "deny" },
// ]

// Format 2: Object format (fine-grained control)
const config2 = {
  permission: [
    { permission: "edit", pattern: "src/**", action: "allow" },
    { permission: "edit", pattern: ".env", action: "deny" },
    { permission: "bash", pattern: "git *", action: "allow" },
  ]
}

The shorthand format is internally converted to rules with pattern: "*". The object format supports path-level fine-grained control.

expand — Path Expansion

The expand() function expands paths before rule matching:

function expand(path: string): string {
  // ~ → User home directory (homedir)
  // $HOME → Actual path
  // ${VAR} → Environment variable value
  return path
    .replace(/^~/, homedir())
    .replace(/\$HOME/g, homedir())
    .replace(/\$\{(\w+)\}/g, (_, varName) => process.env[varName] ?? "")
}

This allows users to use paths like ~/.ssh/* or $HOME/projects/** in their configuration, and Permission automatically expands them to actual paths during matching.

ask/reply Async Flow

When evaluate() returns ask, Permission enters async interaction:

Agent tool call (e.g. edit("src/app.ts"))


Permission.ask({ permission: "edit", patterns: ["src/app.ts"] })

  ├─ disabled("edit") check
  │   └─ If tool is globally denied → throw DeniedError directly

  ├─ evaluate(configRules + approvedRules, "edit", "src/app.ts")
  │   ├─ Returns { action: "allow" } → Allow directly, return
  │   ├─ Returns { action: "deny" }  → Throw DeniedError
  │   └─ Returns { action: "ask" }   → Continue to async flow below

  ├─ Build PendingEntry
  │   └─ { deferred: Effect.Deferred.make(), request }

  ├─ pending.set(id, entry)  // Store in pending queue

  ├─ Bus.publish(Event.Asked, request)  // Broadcast ask event
  │   └─ CLI / TUI subscribes to this event, renders confirmation UI

  └─ Effect.await(deferred)  // Suspend, wait for user response
     │                        // Agent's tool call is paused here


User selects "once" / "always" / "reject" in CLI


Permission.reply(id, reply)

  ├─ Retrieve PendingEntry from pending Map

  ├─ If reply = "once":
  │   ├─ Deferred.succeed()        // Release suspension, tool continues execution
  │   └─ pending.delete(id)        // Clean up pending queue

  ├─ If reply = "always":
  │   ├─ approved.add(matchedRule)  // Add to approved rule set
  │   ├─ persistToSQLite(approved)  // Persist to PermissionTable
  │   ├─ checkPendingRequests()     // Batch check other pending requests
  │   │   └─ Auto-approve satisfied pending requests in the same session
  │   ├─ Deferred.succeed()         // Release suspension
  │   └─ pending.delete(id)

  ├─ If reply = "reject":
  │   ├─ cascadeReject(sessionID)   // Cascade reject all pending requests in session
  │   ├─ Deferred.fail(RejectedError)  // Current request fails
  │   └─ pending.delete(id)

  └─ Bus.publish(Event.Replied, { id, reply })  // Broadcast reply event

Cascade Rejection (cascadeReject)

When a user rejects a permission request, Permission cascades to reject all pending requests in the same session:

function cascadeReject(sessionID: string) {
  // Iterate over pending Map
  for (const [id, entry] of pending) {
    if (entry.request.sessionID === sessionID) {
      // Reject all pending requests in the same session
      Deferred.fail(entry.deferred, new RejectedError(entry.request))
      pending.delete(id)
      Bus.publish(Event.Replied, { id, reply: "reject" })
    }
  }
}

Design intent: When a user rejects a request, it typically means they no longer trust the Agent’s current operation sequence. Cascade rejection avoids a string of meaningless confirmation prompts, allowing the user to quickly regain control.

always Batch Approval

When the user selects always, Permission not only approves the current request but also checks whether other pending requests in the same session are automatically satisfied by the new rule:

function checkPendingRequests(sessionID: string, newRule: Rule) {
  for (const [id, entry] of pending) {
    if (entry.request.sessionID === sessionID) {
      // Re-evaluate pending request with the new rule
      const result = evaluate(
        entry.request.permission,
        entry.request.pattern,
        [newRule],  // The newly approved rule
      )
      if (result.action === "allow") {
        // The new rule satisfies this pending request as well
        Deferred.succeed(entry.deferred)
        pending.delete(id)
        Bus.publish(Event.Replied, { id, reply: "always" })
      }
    }
  }
}

Scenario example: The Agent makes 3 consecutive edit("src/xxx") requests, the first triggers ask. The user selects always, and the latter two requests are automatically satisfied by the new rule without requiring further confirmation.

disabled — Global Tool Disable Check

The disabled() function is called early in ask() to check if a tool is globally denied:

function disabled(tool: string): boolean {
  // Check across all rulesets for { permission: tool, pattern: "*", action: "deny" }
  // This is a fast rejection path, skipping the full evaluate flow
  const rules = [...configRules, ...approvedRules]
  const result = evaluate(tool, "*", rules)
  return result.action === "deny"
}

If disabled() returns true for a tool, ask() throws DeniedError directly without entering the async wait flow. This is a performance optimization — avoiding the creation of Deferred and Bus events for requests that will inevitably be rejected.

BashArity — Bash Command Argument Counts

arity.ts maintains a dictionary of Bash command argument counts:

const BashArity: Record<string, number> = {
  "rm": 1,        // rm requires at least 1 argument (filename)
  "git": 1,       // git requires at least 1 argument (subcommand)
  "npm": 1,       // npm requires at least 1 argument (subcommand)
  "node": 1,      // node requires at least 1 argument (script path)
  "cat": 1,       // cat requires at least 1 argument (filename)
  "echo": 0,      // echo can run with no arguments
  "ls": 0,        // ls can run with no arguments
  // ... more commands
}

This dictionary is used for command splitting during permission pattern matching — determining the boundary between the “tool name” and “arguments” of a Bash command. For example, in rm -rf /tmp/test, rm is the tool name and -rf /tmp/test is the argument. Arity information helps Permission correctly split the command into permission and pattern parts.

User Decision Persistence

Rules from user always selections are persisted to SQLite:

// session.sql.ts
const PermissionTable = sqliteTable("permission", {
  project_id: text("project_id").primaryKey(),
  // ...Timestamps
  data: text("mode", { mode: "json" }).$type<Permission.Ruleset>(),
});
  • Each project (project_id) stores its own Ruleset independently
  • fromConfig() loads initial rules from configuration, approved rules are appended at runtime
  • list() returns all persisted rules for the current project (merged result of config + approved)
  • Persistence shares the SQLite database with Session, linked via project_id

Error Types

The Permission module defines three error types for different scenarios:

// User rejected the operation this time
class RejectedError {
  request: PermissionRequest  // The original rejected request
}

// User provided correction content
class CorrectedError {
  feedback: string            // User's correction notes
  request: PermissionRequest  // Original request
}

// Operation directly denied by rule
class DeniedError {
  ruleset: Ruleset            // The ruleset that triggered the deny (for debugging)
  request: PermissionRequest  // The rejected request
}
  • RejectedError: user actively rejected. The Agent can catch this and adjust its strategy or abandon the operation
  • CorrectedError: user not only rejected but also provided correction content. The feedback field contains the user’s specific instructions, allowing the Agent to re-plan accordingly. This is particularly useful when the user disagrees with the Agent’s direction
  • DeniedError: directly denied by a configuration rule. The ruleset field helps developers debug “why was the operation rejected”

Call Chain Examples

Chain 1: Agent edits a file (ask → always)

Agent initiates tool call edit(path="src/app.ts")


Permission.ask({
  permission: "edit",
  patterns: ["src/app.ts"],
  sessionID: "sess-abc",
})

  ├─ disabled("edit") → false (not globally disabled)

  ├─ evaluate("edit", "src/app.ts", configRules, approvedRules)
  │   ├─ Iterate all rules (findLast semantics):
  │   │   rule: { permission: "read", pattern: "*", action: "allow" } → permission doesn't match "edit"
  │   │   rule: { permission: "edit", pattern: ".env", action: "deny" } → pattern doesn't match "src/app.ts"
  │   │   No more matching rules
  │   └─ Return default: { action: "ask", permission: "edit", pattern: "*" }

  ├─ action = "ask" → Enter async flow
  │   ├─ Deferred.make() → Create async suspension point
  │   ├─ pending.set("req-1", { deferred, request })
  │   ├─ Bus.publish(Event.Asked, { id: "req-1", permission: "edit", pattern: "src/app.ts" })
  │   └─ await(deferred) → Agent tool call suspended


CLI renders confirmation prompt:
  "Agent requests to edit src/app.ts [Allow Once] [Always Allow] [Reject]"


User selects "Always Allow"


Permission.reply("req-1", "always")

  ├─ approved.add({ permission: "edit", pattern: "src/app.ts", action: "allow" })
  ├─ PermissionTable.update(projectID, approved) → SQLite write

  ├─ checkPendingRequests("sess-abc", newRule)
  │   └─ Iterate pending Map, check if other requests in same session are satisfied by new rule
  │   └─ No other pending requests

  ├─ Deferred.succeed(deferred) → Agent tool call resumes
  ├─ pending.delete("req-1")
  └─ Bus.publish(Event.Replied, { id: "req-1", reply: "always" })

Chain 2: Bash command blocked by deny rule

Agent initiates tool call bash(command="rm -rf /")


Permission.ask({
  permission: "bash",
  patterns: ["rm -rf /"],
  sessionID: "sess-abc",
})

  ├─ disabled("bash") → false

  ├─ evaluate("bash", "rm -rf /", configRules, approvedRules)
  │   ├─ In configRules:
  │   │   { permission: "bash", pattern: "rm -rf *", action: "deny" }
  │   │   → Wildcard.match("rm -rf /", "rm -rf *") → true ✓
  │   │   → Wildcard.match("bash", "bash") → true ✓
  │   │   → Match! action = "deny"
  │   └─ Return: { permission: "bash", pattern: "rm -rf *", action: "deny" }

  ├─ action = "deny" → Throw DeniedError directly
  │   └─ Carries the triggering rule { permission: "bash", pattern: "rm -rf *", action: "deny" }


Agent catches DeniedError → Adjusts strategy, does not execute the command

Chain 3: MCP tool call (ask → once)

Agent calls MCP tool github.create_issue


Permission.ask({
  permission: "github.create_issue",
  patterns: ["create issue with title..."],
  sessionID: "sess-abc",
})

  ├─ disabled("github.create_issue") → false

  ├─ evaluate("github.create_issue", "...", configRules, approvedRules)
  │   └─ No matching rules → Default { action: "ask", ... }

  ├─ Async flow → Bus.publish(Event.Asked)


CLI renders confirmation prompt:
  "Agent requests to call github.create_issue [Allow Once] [Always Allow] [Reject]"


User selects "Allow Once"


Permission.reply("req-2", "once")

  ├─ Deferred.succeed() → Agent tool call resumes
  ├─ pending.delete("req-2")
  └─ Bus.publish(Event.Replied, { id: "req-2", reply: "once" })


Next time Agent calls github.create_issue → Still requires asking (because only "once" was approved)

Chain 4: Cascade rejection scenario

Agent makes 3 parallel tool calls (same session):
  req-1: edit("src/a.ts")   → ask (in pending)
  req-2: edit("src/b.ts")   → ask (in pending)
  req-3: bash("npm test")   → ask (in pending)

pending Map:
  req-1 → { deferred: d1, request: { sessionID: "sess-abc", ... } }
  req-2 → { deferred: d2, request: { sessionID: "sess-abc", ... } }
  req-3 → { deferred: d3, request: { sessionID: "sess-abc", ... } }

User selects "Reject" for req-1


Permission.reply("req-1", "reject")

  ├─ Deferred.fail(d1, RejectedError)  → req-1 fails

  ├─ cascadeReject("sess-abc")
  │   ├─ req-2 belongs to same session → Deferred.fail(d2, RejectedError)
  │   │   └─ Bus.publish(Event.Replied, { id: "req-2", reply: "reject" })
  │   └─ req-3 belongs to same session → Deferred.fail(d3, RejectedError)
  │       └─ Bus.publish(Event.Replied, { id: "req-3", reply: "reject" })

  └─ All 3 requests rejected, Agent receives 3 RejectedErrors → Stops current operation sequence

Chain 5: always batch approval scenario

Agent makes 3 consecutive edit requests (same session):
  req-1: edit("src/a.ts")   → ask (in pending)
  req-2: edit("src/b.ts")   → ask (in pending)
  req-3: edit("src/c.ts")   → ask (in pending)

User selects "Always Allow" for req-1


Permission.reply("req-1", "always")

  ├─ approved.add({ permission: "edit", pattern: "src/a.ts", action: "allow" })

  ├─ checkPendingRequests("sess-abc", newRule)
  │   ├─ req-2: evaluate("edit", "src/b.ts", [newRule])
  │   │   → pattern "src/a.ts" doesn't match "src/b.ts" → Not satisfied
  │   └─ req-3: evaluate("edit", "src/c.ts", [newRule])
  │       → pattern "src/a.ts" doesn't match "src/c.ts" → Not satisfied

  └─ Only req-1 is approved, req-2/req-3 still require user confirmation

--- Contrast scenario: User selects allow with "src/**" pattern ---

Assume the rule is { permission: "edit", pattern: "src/**", action: "allow" }

  checkPendingRequests("sess-abc", newRule)
    ├─ req-2: evaluate("edit", "src/b.ts", [newRule])
    │   → Wildcard.match("src/b.ts", "src/**") → true ✓
    │   → Auto-approved! Deferred.succeed(d2)
    └─ req-3: evaluate("edit", "src/c.ts", [newRule])
        → Wildcard.match("src/c.ts", "src/**") → true ✓
        → Auto-approved! Deferred.succeed(d3)

Chain 6: Permission call in Doom Loop detection

SessionProcessor detects Agent calling the same tool 3 times consecutively (with same arguments)


Permission.ask({
  permission: "doom_loop",
  patterns: [toolName],     // e.g. ["edit"]
  sessionID: "sess-abc",
})

  ├─ evaluate("doom_loop", "edit", configRules, approvedRules)
  │   └─ Usually no specific rule → Default ask

  ├─ CLI prompt:
  │   "Agent called edit 3 times consecutively (same arguments), continue?"


User selects "Always Allow"
  │  → approved.add({ permission: "doom_loop", pattern: "edit", action: "allow" })
  │  → Subsequent doom loops for the same tool will not ask again

Design Tradeoffs

DecisionRationale
last-match-wins vs first-match-winslast-match-wins was chosen because later rules in configuration files are typically more specific (e.g. deny * first, then allow src/**), better matching the intuition of “declare general rules first, then declare exceptions”. This is also consistent with rule strategies in mature systems like Apache and Nginx
Effect Deferred async modelCompared to callbacks or Promises, Effect Deferred natively supports cancellation (via AbortController) and timeouts, suitable for long-running Agent scenarios. Deferred can be resolved/failed externally, perfectly matching Permission’s ask/reply pattern
Project-level persistencePermission decisions are bound to project_id rather than global, preventing cross-project permission leakage. Permissions approved with always in project A do not automatically apply to project B
Cascade rejectionRejecting one request cascades to reject all pending requests in the same session, preventing the Agent from continuing meaningless operation sequences after being rejected
always batch checkAfter always, automatically checking if other pending requests in the same session are satisfied by the new rule reduces the number of repeated user confirmations
Wildcard regex conversionConverting glob patterns to regular expressions rather than writing a custom glob matcher keeps code concise with guaranteed correctness. Performance is not a bottleneck in the Permission context (rule count is typically under 100)
Path normalizationReplacing \ with / before matching ensures consistent rule behavior on both Windows and Unix systems
Pure function evaluateevaluate() is a side-effect-free pure function, easy to test and understand. All side effects (persistence, event broadcasting) are handled in ask()/reply()
catchall defaults to askConfig’s Permission Schema catchall(PermissionAction.default("ask")) ensures new tool types are not accidentally allowed
Separate evaluate.tsIsolating evaluation logic into a 15-line pure function file decouples it from index.ts’s Service logic, facilitating unit testing

Relationships with Other Modules

  • Agent: Agent calls Permission.ask() before executing each tool call, waiting for approval or rejection. Agent catches RejectedError / DeniedError and can adjust its strategy. Doom Loop detection also requests user confirmation via Permission.ask()
  • Config: Permission rules are loaded from Config’s permission field via Permission.fromConfig(). Config’s Permission Schema defines independent rules for 14+ operations, with catchall ensuring new tools are safe
  • CLI / TUI: Subscribe to Event.Asked to render confirmation UI; user actions trigger Permission.reply(). TUI can also display current permission rules via Permission.list()
  • MCP: MCP tool calls also go through Permission control, with the permission field using server.tool format (e.g. github.create_issue)
  • Session: PermissionTable is defined in session.sql.ts, sharing the SQLite database with Session. The sessionID parameter of ask() is used as the grouping basis for cascade rejection and batch approval
  • Storage: The persisted approved Ruleset is written to SQLite’s PermissionTable through the Storage layer
  • Bus: Event.Asked and Event.Replied are broadcast via Bus, all UI layers (CLI, TUI, API) can subscribe to these events
  • Wildcard (util): evaluate() depends on Wildcard.match() for wildcard pattern matching, serving as the foundational capability for Permission decisions