Skip to content

Permission Model

Claude Code implements one of the most sophisticated permission systems in any AI coding tool. Every tool invocation — whether it’s editing a file, running a bash command, or spawning a sub-agent — passes through a multi-layered permission decision pipeline. This chapter dissects the complete permission model from type definitions to runtime decisions.

The permission system is defined across several key files:

FilePurpose
src/types/permissions.tsCore type definitions, mode enums, decision types
src/utils/permissions/PermissionMode.tsMode configuration, display names, symbols
src/utils/permissions/permissions.tsMain permission checking logic (hasPermissionsToUseTool)
src/utils/permissions/permissionsLoader.tsLoads rules from disk (settings files)
src/utils/permissions/permissionSetup.tsInitial permission context setup, dangerous rule detection
src/utils/permissions/permissionRuleParser.tsRule string parsing (Bash(npm install:*) → structured value)

Every Claude Code session operates in exactly one permission mode at any time. The mode determines the baseline behavior when no explicit rule matches.

src/types/permissions.ts
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'

The standard mode. Read-only operations are auto-allowed. Write operations and bash commands that aren’t covered by explicit allow rules trigger a permission prompt for the user.

src/utils/permissions/PermissionMode.ts
default: {
title: 'Default',
shortTitle: 'Default',
symbol: '',
color: 'text',
external: 'default',
},

File edits within the project directory are auto-approved. Bash commands still require per-command approval unless covered by rules. This mode is used as a “fast-path check” by the auto mode classifier — if a tool invocation would be allowed under acceptEdits, it skips the expensive classifier API call.

All tool invocations are auto-approved except for:

  • Explicit deny rules (step 1a/1d in the pipeline)
  • Explicit ask rules with content-specific patterns (step 1f)
  • Safety checks on sensitive paths like .git/, .claude/, shell configs (step 1g)
// src/utils/permissions/permissions.ts (line ~1268)
const shouldBypassPermissions =
appState.toolPermissionContext.mode === 'bypassPermissions' ||
(appState.toolPermissionContext.mode === 'plan' &&
appState.toolPermissionContext.isBypassPermissionsModeAvailable)

4. plan — Plan Mode (Read-Only Planning)

Section titled “4. plan — Plan Mode (Read-Only Planning)”

The model can read files and plan actions but cannot execute writes. When the user originally started with bypass mode, plan mode remembers this via isBypassPermissionsModeAvailable and can transition back.

5. dontAsk — Auto-Deny on Permission Prompts

Section titled “5. dontAsk — Auto-Deny on Permission Prompts”

Any tool invocation that would normally trigger a permission prompt is instead auto-denied. The model receives a denial message and must find an alternative approach.

// src/utils/permissions/permissions.ts (line ~508)
if (appState.toolPermissionContext.mode === 'dontAsk') {
return {
behavior: 'deny',
decisionReason: { type: 'mode', mode: 'dontAsk' },
message: DONT_ASK_REJECT_MESSAGE(tool.name),
}
}

An AI classifier evaluates each tool invocation against the conversation transcript. The classifier can auto-approve safe operations or auto-deny dangerous ones. Falls back to user prompting when the classifier is unavailable or hits denial limits.

ModeFile readsFile writesSafe bashDangerous bashSub-agents
default✅ Auto❓ Ask✅ Auto (read-only)❓ Ask❓ Ask
acceptEdits✅ Auto✅ Auto (in project)✅ Auto (read-only)❓ Ask❓ Ask
bypassPermissions✅ Auto✅ Auto✅ Auto✅ Auto*✅ Auto*
plan✅ Auto❌ Deny❌ Deny❌ Deny❌ Deny
dontAsk✅ Auto❌ Deny✅ Auto (read-only)❌ Deny❌ Deny
auto✅ Auto🤖 Classifier🤖 Classifier🤖 Classifier🤖 Classifier

* Except safety checks on .git/, .claude/, shell configs which always prompt

Every permission check returns one of three core behaviors:

src/types/permissions.ts
export type PermissionBehavior = 'allow' | 'deny' | 'ask'

Internally, a fourth behavior passthrough is used to indicate “no rule matched, continue to next check”:

export type PermissionResult<Input> =
| PermissionDecision<Input>
| {
behavior: 'passthrough'
message: string
suggestions?: PermissionUpdate[]
}

The main entry point is hasPermissionsToUseTool in permissions.ts. It delegates to hasPermissionsToUseToolInner, which implements a numbered step pipeline:

flowchart TD
Start[Tool Invocation] --> Step1a
subgraph "Phase 1: Rule-Based Checks"
Step1a{1a. Entire tool<br/>denied by rule?}
Step1a -->|Yes| Deny1[DENY]
Step1a -->|No| Step1b
Step1b{1b. Entire tool<br/>has ask rule?}
Step1b -->|Yes, no sandbox| Ask1[ASK]
Step1b -->|No / sandbox| Step1c
Step1c[1c. Tool.checkPermissions<br/>tool-specific checks]
Step1c --> Step1d
Step1d{1d. Tool impl<br/>denied?}
Step1d -->|Yes| Deny2[DENY]
Step1d -->|No| Step1e
Step1e{1e. Requires user<br/>interaction?}
Step1e -->|Yes + ask| Ask2[ASK]
Step1e -->|No| Step1f
Step1f{1f. Content-specific<br/>ask rule?}
Step1f -->|Yes| Ask3[ASK]
Step1f -->|No| Step1g
Step1g{1g. Safety check<br/>sensitive path?}
Step1g -->|Yes| Ask4[ASK]
Step1g -->|No| Phase2
end
subgraph "Phase 2: Mode-Based Decisions"
Phase2{2a. Bypass<br/>permissions?}
Phase2 -->|Yes| Allow1[ALLOW]
Phase2 -->|No| Step2b
Step2b{2b. Entire tool<br/>always allowed?}
Step2b -->|Yes| Allow2[ALLOW]
Step2b -->|No| Step3
end
subgraph "Phase 3: Final Resolution"
Step3[3. Convert passthrough<br/>to ask] --> PostProcess
PostProcess{Mode post-processing}
PostProcess -->|dontAsk| DenyFinal[DENY]
PostProcess -->|auto| Classifier[AI Classifier]
PostProcess -->|headless| DenyHeadless[DENY]
PostProcess -->|default| AskFinal[ASK user]
end
Classifier -->|approve| AllowClassifier[ALLOW]
Classifier -->|block| DenyClassifier[DENY]
Classifier -->|unavailable| FallbackAsk[ASK / DENY]

Phase 1: Rule-Based Checks (Bypass-Immune)

Section titled “Phase 1: Rule-Based Checks (Bypass-Immune)”

These checks run regardless of mode — even bypassPermissions cannot skip them:

// src/utils/permissions/permissions.ts (line ~1169)
async function hasPermissionsToUseToolInner(tool, input, context) {
// 1a. Entire tool is denied
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
if (denyRule) {
return { behavior: 'deny', ... }
}
// 1b. Entire tool should always ask
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
if (askRule) { ... }
// 1c. Tool-specific permission check
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
// 1d. Tool denied
// 1e. Requires user interaction
// 1f. Content-specific ask rules
// 1g. Safety checks (sensitive paths)
}

After rule-based checks pass, the mode determines the default behavior:

// Step 2a: Bypass mode
if (shouldBypassPermissions) {
return { behavior: 'allow', decisionReason: { type: 'mode', mode: ... } }
}
// Step 2b: Tool-level allow rule
const alwaysAllowedRule = toolAlwaysAllowedRule(context, tool)
if (alwaysAllowedRule) {
return { behavior: 'allow', decisionReason: { type: 'rule', rule: ... } }
}

Phase 3: Post-Processing (Mode Transformations)

Section titled “Phase 3: Post-Processing (Mode Transformations)”

The outer hasPermissionsToUseTool wrapper applies mode-specific transformations to ask results:

  1. dontAsk mode → converts ask to deny
  2. auto mode → routes to AI classifier instead of user prompt
  3. headless/async agents → runs PermissionRequest hooks, then auto-deny if no hook provides a decision

Rules are the core mechanism for fine-grained access control. Each rule has three components:

src/types/permissions.ts
export type PermissionRule = {
source: PermissionRuleSource // where the rule came from
ruleBehavior: PermissionBehavior // allow, deny, or ask
ruleValue: PermissionRuleValue // tool + optional content pattern
}
export type PermissionRuleValue = {
toolName: string // e.g., "Bash", "Edit", "mcp__server__tool"
ruleContent?: string // e.g., "npm install:*", "git commit"
}

Rules are stored as strings and parsed by permissionRuleParser.ts:

Rule StringParsed ValueMeaning
Bash{ toolName: "Bash" }Entire Bash tool
Bash(npm install){ toolName: "Bash", ruleContent: "npm install" }Exact command
Bash(git:*){ toolName: "Bash", ruleContent: "git:*" }Prefix pattern
Bash(*){ toolName: "Bash" }Same as Bash (wildcard = whole tool)
Edit{ toolName: "Edit" }Entire Edit tool
mcp__server1{ toolName: "mcp__server1" }All tools from MCP server

Rules originate from 7 different sources, each with different persistence and priority:

src/types/permissions.ts
export type PermissionRuleSource =
| 'userSettings' // ~/.claude/settings.json
| 'projectSettings' // .claude/settings.json (shared, committed)
| 'localSettings' // .claude/settings.local.json (gitignored)
| 'flagSettings' // --settings CLI flag file
| 'policySettings' // Managed enterprise settings
| 'cliArg' // --allowed-tools, --denied-tools CLI args
| 'command' // /allowed-tools command during session
| 'session' // In-memory session rules (user approval)

Rules are matched in priority order: deny > ask > allow.

For each behavior, rules are collected from all sources and matched against the tool invocation:

src/utils/permissions/permissions.ts
export function getAllowRules(context: ToolPermissionContext): PermissionRule[] {
return PERMISSION_RULE_SOURCES.flatMap(source =>
(context.alwaysAllowRules[source] || []).map(ruleString => ({
source,
ruleBehavior: 'allow',
ruleValue: permissionRuleValueFromString(ruleString),
})),
)
}

When a user approves a tool use, the rule is saved to the session source. It lasts until Claude Code exits.

Local Settings (Project-Specific, Private)

Section titled “Local Settings (Project-Specific, Private)”

.claude/settings.local.json is gitignored. Rules saved here persist across sessions but stay on the developer’s machine.

.claude/settings.json is committed to the repository. Rules here apply to all team members.

~/.claude/settings.json applies across all projects for this user.

managed-settings.json or remote API settings. When allowManagedPermissionRulesOnly is enabled, only policy rules are respected — all other sources are cleared:

src/utils/permissions/permissionsLoader.ts
export function loadAllPermissionRulesFromDisk(): PermissionRule[] {
if (shouldAllowManagedPermissionRulesOnly()) {
return getPermissionRulesForSource('policySettings')
}
// Otherwise, load from all enabled sources
const rules: PermissionRule[] = []
for (const source of getEnabledSettingSources()) {
rules.push(...getPermissionRulesForSource(source))
}
return rules
}

All permission state is bundled into a single immutable context object that flows through the entire pipeline:

src/types/permissions.ts
export type ToolPermissionContext = {
readonly mode: PermissionMode
readonly additionalWorkingDirectories: ReadonlyMap<string, AdditionalWorkingDirectory>
readonly alwaysAllowRules: ToolPermissionRulesBySource
readonly alwaysDenyRules: ToolPermissionRulesBySource
readonly alwaysAskRules: ToolPermissionRulesBySource
readonly isBypassPermissionsModeAvailable: boolean
readonly strippedDangerousRules?: ToolPermissionRulesBySource
readonly shouldAvoidPermissionPrompts?: boolean
readonly awaitAutomatedChecksBeforeDialog?: boolean
readonly prePlanMode?: PermissionMode
}

Key fields:

  • alwaysAllowRules / alwaysDenyRules / alwaysAskRules: Rules grouped by source, enabling efficient lookup without re-parsing
  • shouldAvoidPermissionPrompts: When true (headless/async agents), ask decisions are converted to deny
  • isBypassPermissionsModeAvailable: Tracks whether the user started in bypass mode before switching to plan
  • strippedDangerousRules: Rules removed during auto mode entry (dangerous patterns like Bash(python:*))

Every decision carries a decisionReason that explains why the permission was granted or denied. This is essential for debugging and for the UI to show meaningful messages:

export type PermissionDecisionReason =
| { type: 'rule'; rule: PermissionRule }
| { type: 'mode'; mode: PermissionMode }
| { type: 'subcommandResults'; reasons: Map<string, PermissionResult> }
| { type: 'hook'; hookName: string; reason?: string }
| { type: 'classifier'; classifier: string; reason: string }
| { type: 'safetyCheck'; reason: string; classifierApprovable: boolean }
| { type: 'sandboxOverride'; reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }
| { type: 'workingDir'; reason: string }
| { type: 'asyncAgent'; reason: string }
| { type: 'other'; reason: string }

The safetyCheck reason is particularly interesting — it includes a classifierApprovable flag that determines whether the auto mode classifier is allowed to override the safety check. Sensitive file paths (.claude/, .git/, shell configs) are classifierApprovable: true, while Windows path bypass attempts are classifierApprovable: false.

Auto mode includes a denial tracking system that prevents infinite loops of classifier denials:

// Referenced in permissions.ts
const denialState = context.localDenialTracking ??
appState.denialTracking ??
createDenialTrackingState()
// When denial limit hit, fall back to user prompting
const denialLimitResult = handleDenialLimitExceeded(newDenialState, ...)

When consecutive denials exceed the limit, the system falls back to user prompting. In headless mode, it throws an AbortError to prevent runaway agent loops.

The permission model follows a defense-in-depth principle:

  1. Deny rules are checked first and cannot be bypassed by any mode
  2. Safety checks are bypass-immune — even bypassPermissions respects them
  3. Mode determines the default behavior for uncovered operations
  4. Rules provide fine-grained control at the tool and command level
  5. Multiple persistence levels support both team and individual preferences
  6. Decision reasons create an audit trail for every permission decision