Claude Code's permission system is a hierarchical, rule-based model that checks every tool invocation before execution. It balances safety (preventing destructive actions) with usability (not over-prompting the user). The core entry point is hasPermissionsToUseTool in src/utils/permissions/permissions.ts, which implements a carefully ordered pipeline of checks.
Tool Invocation
|
v
+-------------------+
| Deny Rules |---- Match ----> BLOCKED (with explanation)
| (highest prio) |
+--------+----------+
| No match
v
+-------------------+
| Allow Rules |---- Match ----> ALLOWED (immediate execution)
| |
+--------+----------+
| No match
v
+-------------------+
| Permission Mode |---> default: ASK user
| Default Action |---> plan: read-only only
| |---> auto: classifier decides
| |---> bypassPermissions: allow all
| |---> acceptEdits: allow file writes in CWD
| |---> dontAsk: deny (no prompts)
+-------------------+
Rule sources are defined in PermissionRuleSource (src/types/permissions.ts):
- CLI arguments (
cliArg) ----allowedTools,--disallowedToolsflags - Organization/MDM policy (
policySettings) -- managed enterprise settings; whenallowManagedPermissionRulesOnlyis true, all other sources are ignored - Flag settings (
flagSettings) -- feature flag controlled settings - Local settings (
localSettings) --.claude/local-settings.json(gitignored) - Project settings (
projectSettings) --.claude/settings.json(committed to repo) - User settings (
userSettings) --~/.claude/settings.json - Command-level (
command) -- from command definitions - Session-level (
session) -- accumulated during conversation
Rules from all sources are flattened into three maps on ToolPermissionContext: alwaysAllowRules, alwaysDenyRules, and alwaysAskRules, each keyed by source.
The PermissionBehavior type defines three outcomes:
- allow: Immediate execution; may include an
updatedInput(e.g., input rewritten by hooks) and adecisionReasonfor audit - ask: Permission dialog shown to user with tool details and suggested future rules
- deny: Blocked immediately with explanation message returned to the LLM
- passthrough (internal): Tool's own
checkPermissionshas no opinion; converted toaskat step 3 of the pipeline
Every permission decision carries a typed PermissionDecisionReason that traces exactly why the decision was made:
| Type | Meaning |
|---|---|
rule |
Matched an allow/deny/ask rule from settings |
mode |
Decision made by the current permission mode |
classifier |
Auto mode transcript classifier decided |
hook |
A PreToolUse or PermissionRequest hook intervened |
subcommandResults |
Bash compound command; per-subcommand results aggregated |
safetyCheck |
Bypass-immune safety check (e.g., writing to .git/, .claude/) |
sandboxOverride |
Command excluded from sandbox or dangerouslyDisableSandbox used |
workingDir |
Path outside allowed working directories |
asyncAgent |
Headless/background agent context where prompts are unavailable |
permissionPromptTool |
External tool handling permission decisions |
other |
Catch-all with a reason string |
Defined in src/types/permissions.ts as InternalPermissionMode:
| Mode | Behavior | Use Case |
|---|---|---|
default |
Ask for write operations, allow reads | Normal interactive use |
plan |
Only read-only tools allowed; if user started in bypass mode, bypass still applies | Planning phase, no side effects |
acceptEdits |
Allow file writes in the working directory without prompting | Trusted edit operations |
auto |
Transcript classifier makes binary allow/deny decisions | Autonomous operation |
dontAsk |
Convert all ask decisions to deny |
Non-interactive mode that refuses rather than prompts |
bypassPermissions |
Allow everything except bypass-immune safety checks | Trusted environments, CI/CD |
bubble |
Internal mode for cross-agent permission delegation | Swarm/team coordination |
- Users can switch modes during a session via
/plan,/auto, or settings changes planmode remembers the pre-plan mode (prePlanMode) so it can restore the original mode on exit- When
bypassPermissionswas the original mode and user entersplan, bypass still applies (viaisBypassPermissionsModeAvailable)
The core permission check in hasPermissionsToUseToolInner follows this exact sequence:
Step 1a -- Tool-level deny rules: If the entire tool matches a deny rule (e.g., "deny": ["Bash"]), immediately return deny. MCP tools support server-level deny (e.g., mcp__server1 denies all tools from that server).
Step 1b -- Tool-level ask rules: If the entire tool matches an ask rule, return ask. Exception: if sandbox auto-allow is enabled and the Bash command would be sandboxed, fall through to let Bash's own checkPermissions handle it.
Step 1c -- Tool-specific permission check: Calls tool.checkPermissions(parsedInput, context). Each tool implements its own logic here. For example, BashTool analyzes subcommands against prefix-match allow/deny rules, EditTool checks path safety, etc. Returns a PermissionResult which may be passthrough (no opinion), allow, ask, or deny.
Step 1d -- Tool implementation denied: If checkPermissions returned deny, respect it immediately.
Step 1e -- User interaction required: If the tool declares requiresUserInteraction() and checkPermissions returned ask, respect it even in bypass mode.
Step 1f -- Content-specific ask rules: If checkPermissions returned ask with a decisionReason of type rule with ruleBehavior: 'ask', respect it even in bypass mode. This ensures explicit ask rules like Bash(npm publish:*) always prompt.
Step 1g -- Safety checks (bypass-immune): If checkPermissions returned ask with decisionReason.type === 'safetyCheck', respect it even in bypass mode. This covers writing to .git/, .claude/, .vscode/, shell config files, etc.
Step 2a -- Bypass permissions: If mode is bypassPermissions, or mode is plan with bypass originally available, allow the tool.
Step 2b -- Tool-level allow rules: If the entire tool matches an allow rule (e.g., "allow": ["Edit"]), allow immediately.
Step 3 -- Convert passthrough to ask: If the tool's checkPermissions returned passthrough, convert it to ask with an appropriate message. Otherwise, return the tool's own decision (which is ask or allow at this point).
After hasPermissionsToUseToolInner returns, the outer hasPermissionsToUseTool applies mode-specific transformations to ask results:
- dontAsk mode: Converts
asktodenywith a specific rejection message - auto mode: Routes to the transcript classifier (see below)
- shouldAvoidPermissionPrompts: For headless agents, runs PermissionRequest hooks first; if no hook decides, auto-denies
A rule like "Bash" (no parentheses) matches the entire BashTool. For MCP tools, "mcp__server1" matches all tools from that server, and "mcp__server1__*" is also supported.
Rules with parentheses like "Bash(npm install)" use content matching. The tool name is extracted from before the first unescaped (, and the content from between the parentheses. Parentheses in content must be escaped: "Bash(python -c \"print\\(1\\)\")".
The permissionRuleParser.ts handles parsing with proper escape/unescape of backslashes and parentheses. Empty content "Bash()" and standalone wildcard "Bash(*)" are normalized to tool-level rules.
Renamed tools are supported via LEGACY_TOOL_NAME_ALIASES: Task maps to Agent, KillShell maps to TaskStop, etc.
When entering auto mode, permissionSetup.ts strips allow rules that would bypass the classifier:
- Bash: Tool-level allow, prefix rules for interpreters (
python:*,node:*,ssh:*), wildcard rules for code execution entry points. The full list is inDANGEROUS_BASH_PATTERNSandCROSS_PLATFORM_CODE_EXECindangerousPatterns.ts. - PowerShell: Same patterns plus PS-specific cmdlets (
Invoke-Expression,Start-Process,Add-Type,New-Object, etc.) and their aliases. - Agent: Any allow rule for the Agent tool is dangerous because it would auto-approve sub-agent spawns before the classifier can evaluate their prompt.
When permission mode is auto and a tool check returns ask:
-
Non-classifier-approvable safety checks: If the
safetyCheckdecision hasclassifierApprovable: false(e.g., Windows path bypass attempts), deny immediately. IfclassifierApprovable: true(e.g., sensitive file paths like.claude/), the classifier is allowed to evaluate. -
acceptEdits fast path: Re-runs
tool.checkPermissionswith mode temporarily set toacceptEdits. If the tool would be allowed under that mode (e.g., file writes within the working directory), auto-allow without calling the classifier. Excluded for Agent and REPL tools. -
Safe tool allowlist:
classifierDecision.tsdefinesSAFE_YOLO_ALLOWLISTED_TOOLS-- read-only tools (FileRead, Grep, Glob, LSP, ToolSearch), task management tools, plan mode tools, and swarm coordination tools. These skip the classifier entirely.
If no fast path applies:
- Format the tool call as an action string via
formatActionForClassifier - Call
classifyYoloActionwhich sends the conversation transcript plus the pending tool call to a side-query LLM - The classifier uses a two-stage approach (configurable): a fast stage and a thinking stage
- Binary decision:
shouldBlock: true/falsewith areasonstring
- API error + iron gate closed (feature flag
tengu_iron_gate_closed): Fail closed -- deny with retry guidance - API error + iron gate open: Fail open -- fall back to normal permission prompting
- Transcript too long: Deterministic error (transcript only grows); fall back to manual approval in CLI mode, abort in headless mode
denialTracking.ts implements consecutive and total denial limits:
- 3 consecutive denials: Fall back to prompting the user
- 20 total denials in a session: Fall back to prompting, then reset counter
- In headless mode, hitting either limit throws an
AbortErrorto stop the agent - Any successful tool use resets the consecutive denial counter
When a tool requires user approval (ask decision), the permissionExplainer.ts generates a human-readable explanation:
- Sends the tool name, input, and recent conversation context to a side-query LLM
- Uses forced tool choice for structured output with fields:
explanation,reasoning,risk,riskLevel(LOW/MEDIUM/HIGH) - The explanation appears in the permission dialog to help users make informed decisions
- Can be disabled via
permissionExplainerEnabled: falsein global config - Aborted requests and errors silently return
null-- the dialog still works without the explanation
The sandbox system interacts with permissions in two ways:
-
autoAllowBashIfSandboxed: When sandbox is enabled and this setting is true (default on macOS), Bash commands that will run inside the sandbox skip ask rules and auto-allow. Commands excluded from sandboxing or using
dangerouslyDisableSandboxstill require permission. -
Sandbox override decisions: When a command is excluded from the sandbox or uses
dangerouslyDisableSandbox, the decision reason is{ type: 'sandboxOverride', reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }.
Three hook event types interact with the permission system:
-
PreToolUse hooks: Run before the tool executes. Can return
allow(skip permission prompt),deny(block), or no decision (fall through to normal flow). Configured insettings.jsonunderhooks. -
PostToolUse hooks: Run after tool execution. Cannot change the permission decision but can modify output.
-
PermissionRequest hooks: Run specifically when a permission prompt would be shown. Critical for headless/async agents that cannot show interactive prompts. A hook can return
allow(with optionalupdatedPermissionsto persist),deny(with optionalinterruptto abort the agent), or no decision.
Hook decisions are logged with decisionReason: { type: 'hook', hookName: '...' }.
When allowManagedPermissionRulesOnly is enabled in policy settings:
- Only permission rules from
policySettingsare loaded from disk - All other sources (user, project, local, CLI, session) are cleared during sync
- The "Always allow" option is hidden from permission prompts (
shouldShowAlwaysAllowOptions()returns false) - New permission rules cannot be persisted by the user
When a tool requires user approval:
- Tool name and input parameters are displayed
- The permission explainer provides a risk assessment and plain-English explanation (if enabled)
- Suggested permission rules are offered based on the tool and input (e.g., "Always allow Edit", "Allow Bash(npm install:*)")
- User can: Allow once, Allow always (creates persistent rule), Deny, or Deny with feedback
- "Allow always" persists to the appropriate settings file (project or user level)
- Permission decisions are logged for analytics with source tracking (user temporary, user permanent, hook, classifier, config)
- Fail-closed: Unknown tools or ambiguous commands default to asking; the auto mode classifier defaults to deny when unavailable (with
iron_gate_closed) - Bypass-immune safety: Certain checks (
.git/,.claude/, shell configs) cannot be overridden even bybypassPermissionsmode - Progressive trust: Users build up allow rules over time; rules persist across sessions in settings files
- Transparency: Every decision carries a typed
PermissionDecisionReasontraceable to its source - Non-blocking optimization: The auto mode classifier uses fast paths (acceptEdits, safe tool allowlist) to avoid expensive API calls for obviously safe operations
- Denial limits: Auto mode cannot get stuck in deny loops; consecutive denial tracking forces fallback to user prompting
- Enterprise control: MDM policy settings override all user/project settings; managed-only mode locks down the permission system
| File | Purpose |
|---|---|
src/types/permissions.ts |
Core type definitions: modes, behaviors, rules, decisions, reasons |
src/utils/permissions/permissions.ts |
Main permission pipeline: hasPermissionsToUseTool, rule matching, mode logic |
src/utils/permissions/permissionsLoader.ts |
Loading rules from disk, settings JSON parsing, rule persistence |
src/utils/permissions/permissionSetup.ts |
Initial permission context setup, dangerous pattern stripping |
src/utils/permissions/permissionRuleParser.ts |
Rule string parsing: ToolName(content) format with escape handling |
src/utils/permissions/permissionExplainer.ts |
LLM-powered risk assessment for permission dialogs |
src/utils/permissions/classifierDecision.ts |
Auto mode safe tool allowlist |
src/utils/permissions/yoloClassifier.ts |
Auto mode transcript classifier (LLM side-query) |
src/utils/permissions/dangerousPatterns.ts |
Dangerous allow-rule patterns for Bash/PowerShell |
src/utils/permissions/denialTracking.ts |
Consecutive/total denial limits for auto mode |
src/hooks/toolPermission/permissionLogging.ts |
Analytics and telemetry for all permission decisions |