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.Deferredfor 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.Repliedare broadcast via Bus, allowing both CLI and TUI to subscribe - Project-level persistence: user
alwaysdecisions are written to SQLite, bound toproject_id, preventing cross-project permission leakage - Cascade rejection and batch approval: rejecting one request cascades to reject all pending requests in the same session;
alwaysapproval 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 Path | Lines | Responsibility |
|---|---|---|
src/permission/index.ts | ~326 | Module main entry: Service definition, ask/reply flow, fromConfig parsing, expand path expansion, cascade logic |
src/permission/evaluate.ts | ~15 | Pure function: last-match-wins rule evaluation, findLast semantics |
src/permission/schema.ts | ~18 | PermissionID type definition |
src/permission/arity.ts | ~163 | BashArity dictionary: argument count mapping for Bash commands |
src/session/session.sql.ts | — | PermissionTable definition, SQLite storage for Ruleset |
src/util/wildcard.ts | — | Wildcard.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
asknotconfirm.askemphasizes the “questioning” semantics, corresponding to theReplytype 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: Rulesetand 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 useserver.toolformat (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:
- configRules: initial rules loaded from the Config file
- 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:
- Multi-Ruleset flattening: accepts variadic
...rulesets, typically[configRules, approvedRules], flattened into a single array - Dual matching: each rule requires both
permissionandpatterndimensions to match - findLast semantics: the last matching rule wins. This allows a “deny all first, then allow specific paths” declaration pattern
- 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 Pattern | Regular Expression | Match 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 ownRulesetindependently fromConfig()loads initial rules from configuration,approvedrules are appended at runtimelist()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
feedbackfield 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
rulesetfield 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
| Decision | Rationale |
|---|---|
| last-match-wins vs first-match-wins | last-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 model | Compared 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 persistence | Permission 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 rejection | Rejecting 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 check | After 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 conversion | Converting 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 normalization | Replacing \ with / before matching ensures consistent rule behavior on both Windows and Unix systems |
| Pure function evaluate | evaluate() 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 ask | Config’s Permission Schema catchall(PermissionAction.default("ask")) ensures new tool types are not accidentally allowed |
| Separate evaluate.ts | Isolating 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 catchesRejectedError/DeniedErrorand can adjust its strategy. Doom Loop detection also requests user confirmation viaPermission.ask() - Config: Permission rules are loaded from Config’s
permissionfield viaPermission.fromConfig(). Config’sPermissionSchema defines independent rules for 14+ operations, withcatchallensuring new tools are safe - CLI / TUI: Subscribe to
Event.Askedto render confirmation UI; user actions triggerPermission.reply(). TUI can also display current permission rules viaPermission.list() - MCP: MCP tool calls also go through Permission control, with the
permissionfield usingserver.toolformat (e.g.github.create_issue) - Session:
PermissionTableis defined insession.sql.ts, sharing the SQLite database with Session. ThesessionIDparameter ofask()is used as the grouping basis for cascade rejection and batch approval - Storage: The persisted
approvedRuleset is written to SQLite’sPermissionTablethrough the Storage layer - Bus:
Event.AskedandEvent.Repliedare broadcast via Bus, all UI layers (CLI, TUI, API) can subscribe to these events - Wildcard (util):
evaluate()depends onWildcard.match()for wildcard pattern matching, serving as the foundational capability for Permission decisions