Skip to content

Latest commit

 

History

History
271 lines (185 loc) · 16.1 KB

File metadata and controls

271 lines (185 loc) · 16.1 KB

Permission Model

Overview

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.

Architecture

Three-Tier Decision Flow

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 Source Hierarchy (highest to lowest priority)

Rule sources are defined in PermissionRuleSource (src/types/permissions.ts):

  1. CLI arguments (cliArg) -- --allowedTools, --disallowedTools flags
  2. Organization/MDM policy (policySettings) -- managed enterprise settings; when allowManagedPermissionRulesOnly is true, all other sources are ignored
  3. Flag settings (flagSettings) -- feature flag controlled settings
  4. Local settings (localSettings) -- .claude/local-settings.json (gitignored)
  5. Project settings (projectSettings) -- .claude/settings.json (committed to repo)
  6. User settings (userSettings) -- ~/.claude/settings.json
  7. Command-level (command) -- from command definitions
  8. 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.

Decision Outcomes

The PermissionBehavior type defines three outcomes:

  • allow: Immediate execution; may include an updatedInput (e.g., input rewritten by hooks) and a decisionReason for 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 checkPermissions has no opinion; converted to ask at step 3 of the pipeline

Decision Reasons

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

Permission Modes

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

Mode Transitions

  • Users can switch modes during a session via /plan, /auto, or settings changes
  • plan mode remembers the pre-plan mode (prePlanMode) so it can restore the original mode on exit
  • When bypassPermissions was the original mode and user enters plan, bypass still applies (via isBypassPermissionsModeAvailable)

The hasPermissionsToUseToolInner Pipeline

The core permission check in hasPermissionsToUseToolInner follows this exact sequence:

Phase 1: Rule-Based Checks (cannot be overridden by mode)

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.

Phase 2: Mode-Based Decisions

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.

Phase 3: Default Handling

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).

Post-Pipeline Mode Transformations

After hasPermissionsToUseToolInner returns, the outer hasPermissionsToUseTool applies mode-specific transformations to ask results:

  1. dontAsk mode: Converts ask to deny with a specific rejection message
  2. auto mode: Routes to the transcript classifier (see below)
  3. shouldAvoidPermissionPrompts: For headless agents, runs PermissionRequest hooks first; if no hook decides, auto-denies

Rule Matching

Tool-Level Rules

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.

Content-Specific Rules

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.

Legacy Tool Name Aliases

Renamed tools are supported via LEGACY_TOOL_NAME_ALIASES: Task maps to Agent, KillShell maps to TaskStop, etc.

Dangerous Permission Detection

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 in DANGEROUS_BASH_PATTERNS and CROSS_PLATFORM_CODE_EXEC in dangerousPatterns.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.

Auto Mode (Transcript Classifier)

When permission mode is auto and a tool check returns ask:

Fast Paths (skip classifier API call)

  1. Non-classifier-approvable safety checks: If the safetyCheck decision has classifierApprovable: false (e.g., Windows path bypass attempts), deny immediately. If classifierApprovable: true (e.g., sensitive file paths like .claude/), the classifier is allowed to evaluate.

  2. acceptEdits fast path: Re-runs tool.checkPermissions with mode temporarily set to acceptEdits. 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.

  3. Safe tool allowlist: classifierDecision.ts defines SAFE_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.

Classifier Pipeline

If no fast path applies:

  1. Format the tool call as an action string via formatActionForClassifier
  2. Call classifyYoloAction which sends the conversation transcript plus the pending tool call to a side-query LLM
  3. The classifier uses a two-stage approach (configurable): a fast stage and a thinking stage
  4. Binary decision: shouldBlock: true/false with a reason string

Classifier Failure Modes

  • 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

Denial Tracking

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 AbortError to stop the agent
  • Any successful tool use resets the consecutive denial counter

Permission Explainer

When a tool requires user approval (ask decision), the permissionExplainer.ts generates a human-readable explanation:

  1. Sends the tool name, input, and recent conversation context to a side-query LLM
  2. Uses forced tool choice for structured output with fields: explanation, reasoning, risk, riskLevel (LOW/MEDIUM/HIGH)
  3. The explanation appears in the permission dialog to help users make informed decisions
  4. Can be disabled via permissionExplainerEnabled: false in global config
  5. Aborted requests and errors silently return null -- the dialog still works without the explanation

Sandbox Integration

The sandbox system interacts with permissions in two ways:

  1. 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 dangerouslyDisableSandbox still require permission.

  2. Sandbox override decisions: When a command is excluded from the sandbox or uses dangerouslyDisableSandbox, the decision reason is { type: 'sandboxOverride', reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }.

Hooks Integration

Three hook event types interact with the permission system:

  1. PreToolUse hooks: Run before the tool executes. Can return allow (skip permission prompt), deny (block), or no decision (fall through to normal flow). Configured in settings.json under hooks.

  2. PostToolUse hooks: Run after tool execution. Cannot change the permission decision but can modify output.

  3. 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 optional updatedPermissions to persist), deny (with optional interrupt to abort the agent), or no decision.

Hook decisions are logged with decisionReason: { type: 'hook', hookName: '...' }.

Enterprise/MDM Policy

When allowManagedPermissionRulesOnly is enabled in policy settings:

  • Only permission rules from policySettings are 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

Permission Dialog UX

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)

Design Principles

  1. Fail-closed: Unknown tools or ambiguous commands default to asking; the auto mode classifier defaults to deny when unavailable (with iron_gate_closed)
  2. Bypass-immune safety: Certain checks (.git/, .claude/, shell configs) cannot be overridden even by bypassPermissions mode
  3. Progressive trust: Users build up allow rules over time; rules persist across sessions in settings files
  4. Transparency: Every decision carries a typed PermissionDecisionReason traceable to its source
  5. Non-blocking optimization: The auto mode classifier uses fast paths (acceptEdits, safe tool allowlist) to avoid expensive API calls for obviously safe operations
  6. Denial limits: Auto mode cannot get stuck in deny loops; consecutive denial tracking forces fallback to user prompting
  7. Enterprise control: MDM policy settings override all user/project settings; managed-only mode locks down the permission system

Key Source Files

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