markdown.engineering

Lesson 06 — The Permission System

How Claude Code decides whether to run a tool: from deny rules to AI classifiers

1. System Overview

Every time Claude wants to use a tool — run a bash command, edit a file, call an MCP endpoint — the permission system runs a multi-step decision pipeline. The pipeline produces one of three outcomes:

allow

Tool executes immediately

deny

Tool is blocked; Claude is told why

ask

User is prompted to approve or reject

The three inputs to the pipeline are: the tool being requested, its input (e.g., the shell command), and the current permission context (mode, rules from all sources, session state).

2. The Decision Pipeline

The pipeline is implemented in hasPermissionsToUseToolInner() in permissions.ts. Steps are numbered to match the inline comments in the source.

flowchart TD START([Tool use requested]) --> ABORT{Session\naborted?} ABORT -->|yes| THROW([Throw AbortError]) ABORT -->|no| S1A subgraph STEP1["Step 1 — Rule & Safety Checks (always run)"] S1A{Deny rule\nfor tool?} -->|yes| DENY1([deny]) S1A -->|no| S1B S1B{Ask rule\nfor tool?} -->|"yes (no sandbox)"| ASK1([ask]) S1B -->|"no / sandbox allowed"| S1C S1C["tool.checkPermissions(input)"] --> S1D S1D{Result =\ndeny?} -->|yes| DENY2([deny]) S1D -->|no| S1E S1E{requiresUser\nInteraction?} -->|"yes + ask"| ASK2([ask]) S1E -->|no| S1F S1F{Content ask\nrule matched?} -->|yes| ASK3([ask]) S1F -->|no| S1G S1G{Safety check\n.git/.claude/\nshell configs?} -->|yes| ASK4([ask]) S1G -->|no| STEP2 end subgraph STEP2["Step 2 — Mode & Allow Rule Checks"] M1{bypassPermissions\nor plan+bypass?} -->|yes| ALLOW1([allow]) M1 -->|no| M2 M2{Tool-wide\nallow rule?} -->|yes| ALLOW2([allow]) M2 -->|no| PASSTHRU PASSTHRU["Convert passthrough → ask"] end STEP1 --> STEP2 PASSTHRU --> STEP3 subgraph STEP3["Step 3 — Mode Transformations (post-ask)"] T1{mode =\ndontAsk?} -->|yes| DENY3([deny]) T1 -->|no| T2 T2{mode = auto\nor auto+plan?} -->|yes| AUTOF T2 -->|no| T3 T3{shouldAvoid\nPrompts?} -->|yes| HOOKS T3 -->|no| ASK5([ask user]) subgraph AUTOF["Auto Mode Fast Paths"] AF1{Non-classifier\nsafety check?} -->|yes| ASK6([ask/deny]) AF1 -->|no| AF2 AF2{acceptEdits\nwould allow?} -->|yes| ALLOW3([allow]) AF2 -->|no| AF3 AF3{Tool on safe\nallowlist?} -->|yes| ALLOW4([allow]) AF3 -->|no| AF4 AF4["classifyYoloAction()"]:::classifier --> AF5 AF5{shouldBlock?} -->|yes| DENY4([deny/ask on limit]) AF5 -->|no| ALLOW5([allow]) end HOOKS["Run PermissionRequest hooks"] --> HOOKD HOOKD{Hook\ndecision?} -->|allow/deny| HD([hook result]) HOOKD -->|none| DENY5([deny — headless]) end classDef classifier fill:#211b14,stroke:#b8965e,color:#b8965e

Each numbered step corresponds directly to the // 1a, 1b, 1c... comments in permissions.ts, making the source self-documenting.

Step-by-step breakdown
  1. 1a — Deny rule for entire tool: getDenyRuleForTool() checks if the tool itself (e.g., "Bash" with no content) appears in any deny list. Immediate deny if found.
  2. 1b — Ask rule for entire tool: Same check against ask lists. When sandbox auto-allow is on and the command will be sandboxed, this step is skipped and falls through to checkPermissions.
  3. 1c — Tool's own checkPermissions(): Each tool implements its own logic (e.g., Bash checks subcommand prefix rules, FileWrite checks the working directory boundary).
  4. 1d — Tool denied permission: If checkPermissions returns deny, stop immediately.
  5. 1e — requiresUserInteraction: Some tools (ExitPlanMode, AskUserQuestion, ReviewArtifact) always require a human — even bypassPermissions cannot override.
  6. 1f — Content-specific ask rules: A rule like Bash(npm publish:*) that explicitly asks for a command pattern is respected even in bypass mode.
  7. 1g — Safety check (bypass-immune): Writing to .git/, .claude/, .vscode/, shell config files always requires user approval, regardless of mode.
  8. 2a — bypassPermissions mode: If mode is bypassPermissions (or plan with bypass available), everything that passed step 1 is allowed.
  9. 2b — Tool-wide allow rule: toolAlwaysAllowedRule() checks if the entire tool is in the allow list (e.g., "Bash" with no content).
  10. 3 — Mode transformations: An ask result is transformed: dontAskdeny; auto → classifier; headless → hooks → auto-deny.

3. The 5 Permission Modes

The mode controls which pipeline branch executes for any ask result. It is stored in toolPermissionContext.mode.

default

Standard mode. Shows a prompt to the user for every unrecognized tool use.

plan

Read-only planning phase. Write tools are blocked until user exits plan mode.

⏵⏵
acceptEdits

Auto-approves file edits inside the working directory. Shell commands still prompt.

⏵⏵
bypassPermissions

Skips all prompts (except deny rules, content ask rules, safety checks, requiresUserInteraction). Requires gated access.

⏵⏵
dontAsk

Converts every ask into a deny. Claude is told it cannot use the tool.

⏵⏵
auto (ANT-only)

Routes ask decisions through an AI classifier instead of a human prompt. Feature-flagged via TRANSCRIPT_CLASSIFIER.

Source: PermissionMode config from PermissionMode.ts
const PERMISSION_MODE_CONFIG = {
  default:           { title: 'Default',            color: 'text',       external: 'default' },
  plan:              { title: 'Plan Mode',          color: 'planMode',  external: 'plan' },
  acceptEdits:       { title: 'Accept edits',       color: 'autoAccept', external: 'acceptEdits' },
  bypassPermissions: { title: 'Bypass Permissions', color: 'error',      external: 'bypassPermissions' },
  dontAsk:           { title: "Don't Ask",          color: 'error',      external: 'dontAsk' },
  // auto is ANT-only, enabled by feature('TRANSCRIPT_CLASSIFIER')
  auto:              { title: 'Auto mode',          color: 'warning',    external: 'default' },
}

Note: auto reports as default to external users — its existence is internal.

What bypassPermissions cannot bypass
  • Step 1a — deny rules: Always respected, no exceptions.
  • Step 1e — requiresUserInteraction: ExitPlanMode, AskUserQuestion, ReviewArtifact always need a human.
  • Step 1f — content-specific ask rules: Bash(npm publish:*) in the ask list always prompts.
  • Step 1g — safety checks: Writing to .git/, .claude/, .vscode/, shell configs always prompts — even in bypass mode.

4. Rule Matching System

Every rule is a string of the form ToolName or ToolName(content), stored in allow/deny/ask lists. Parsing is handled by permissionRuleParser.ts.

Rule string format

// Tool-wide rule — no content
"Bash"                 // match any Bash command
"mcp__myserver"        // match all tools from myserver MCP
"mcp__myserver__*"     // same, wildcard variant

// Content-specific rules (three types)
"Bash(npm install)"    // exact match
"Bash(npm:*)"          // legacy prefix — matches anything starting with "npm"
"Bash(git add *)"      // wildcard — * matches any sequence of chars
"Bash(git ad\* file)"  // escaped * — matches literal asterisk

Three content rule types (shellRuleMatching.ts)

exact

Full string equality after trimming. Bash(npm install)

prefix (legacy)

Ends with :*. The part before : must be a prefix of the command. Bash(npm:*) matches npm install, npm run build, etc.

wildcard

Contains unescaped *. Converted to a full regex with dotAll flag. Bash(git * --dry-run)

Wildcard pattern edge case: trailing *

When a pattern ends with  * (space + single wildcard), the match engine makes the trailing part optional:

// Rule: "git *"
// Matches both:
"git add"     // command with argument
"git"         // bare command — optional trailing match
// This aligns wildcard semantics with legacy prefix "git:*"

Multi-wildcard patterns (* run *) are excluded from this optimization to avoid false matches.

Rule parsing code (permissionRuleParser.ts)
// "Bash(python -c \"print\\(1\\)\")"  →
function permissionRuleValueFromString(ruleString) {
  const openIdx  = findFirstUnescapedChar(ruleString, '(')
  const closeIdx = findLastUnescapedChar(ruleString, ')')

  if (openIdx === -1) return { toolName: normalizeLegacyToolName(ruleString) }

  const toolName   = ruleString.substring(0, openIdx)
  const rawContent = ruleString.substring(openIdx + 1, closeIdx)

  // Empty or standalone "*" → treat as tool-wide rule
  if (rawContent === '' || rawContent === '*')
    return { toolName: normalizeLegacyToolName(toolName) }

  return { toolName: normalizeLegacyToolName(toolName), ruleContent: unescapeRuleContent(rawContent) }
}
Legacy tool name aliases

Old rule strings in user configs are automatically migrated at parse time:

const LEGACY_TOOL_NAME_ALIASES = {
  Task:            AGENT_TOOL_NAME,     // → "Agent"
  KillShell:       TASK_STOP_TOOL_NAME, // → "TaskStop"
  AgentOutputTool: TASK_OUTPUT_TOOL_NAME,
  BashOutputTool:  TASK_OUTPUT_TOOL_NAME,
}

5. Rule Sources & Priority

Rules are loaded from multiple sources and merged. When allowManagedPermissionRulesOnly is set in policySettings, all non-policy sources are ignored.

Source File / origin Shared? Editable?
policySettings Enterprise managed policy Yes No (read-only)
projectSettings .claude/settings.json (committed) Yes — git Yes
userSettings ~/.claude/settings.json No Yes
localSettings .claude/settings.local.json (gitignored) No Yes
flagSettings --settings CLI flag No No (read-only)
cliArg CLI startup arguments No No (runtime)
session In-memory (this session only) No No (ephemeral)
command Slash command frontmatter Yes No (read-only)

Settings JSON format

{
  "permissions": {
    "allow": ["Bash(npm:*)", "Bash(git status)"],
    "deny":  ["WebFetch"],
    "ask":   ["Bash(npm publish:*)"]
  }
}

6. Auto Mode & the AI Classifier

When mode is auto, instead of prompting the user, Claude routes ambiguous tool uses through a secondary Claude model (the "YOLO classifier") that decides allow/deny based on a system prompt describing allowed and prohibited action categories.

Fast paths (skip the classifier API call)

  1. Non-classifiable safety checks: Writing to .git/ or shell configs — never auto-approved even by the classifier.
  2. acceptEdits fast path: If the tool would be allowed in acceptEdits mode (file edits inside CWD), allow immediately without calling the classifier.
  3. Safe tool allowlist: Read-only tools (FileRead, Grep, Glob, LSP, TodoWrite, sleep, etc.) skip the classifier entirely. The list is in classifierDecision.ts.
Safe tool allowlist (abridged from classifierDecision.ts)
const SAFE_YOLO_ALLOWLISTED_TOOLS = new Set([
  // Read-only file operations
  FILE_READ_TOOL_NAME,
  GREP_TOOL_NAME, GLOB_TOOL_NAME, LSP_TOOL_NAME,
  TOOL_SEARCH_TOOL_NAME, LIST_MCP_RESOURCES_TOOL_NAME,
  // Task management (metadata only)
  TODO_WRITE_TOOL_NAME,
  TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_UPDATE_TOOL_NAME,
  TASK_LIST_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_TOOL_NAME,
  // Plan mode / UI
  ASK_USER_QUESTION_TOOL_NAME,
  ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME,
  // Misc
  SLEEP_TOOL_NAME,
  YOLO_CLASSIFIER_TOOL_NAME, // the classifier itself
])

Denial tracking

The classifier tracks consecutive and total denials to detect runaway loops. When limits are hit, it falls back to prompting the user:

const DENIAL_LIMITS = {
  maxConsecutive: 3,   // 3 denials in a row → prompt user
  maxTotal:      20,   // 20 total denials this session → prompt user
}

function shouldFallbackToPrompting(state) {
  return state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive
      || state.totalDenials       >= DENIAL_LIMITS.maxTotal
}

A successful tool use resets the consecutive count (but not the total).

Dangerous patterns stripped at auto mode entry

When entering auto mode, permissionSetup.ts strips allow rules that would let Claude bypass the classifier entirely:

const DANGEROUS_BASH_PATTERNS = [
  // Script interpreters (arbitrary code execution)
  'python', 'node', 'deno', 'ruby', 'perl', 'php', 'lua',
  // Package runners
  'npx', 'bunx', 'npm run', 'yarn run', 'bun run',
  // Shells & execution multipliers
  'bash', 'sh', 'eval', 'exec', 'sudo', 'xargs',
  // ... plus ANT-only: gh, curl, wget, git, kubectl, aws, gcloud
]

Existing allow rules matching these patterns are removed from the context when auto mode is activated, ensuring the classifier sees all shell commands.

Classifier fail modes

Fail Closed (default)

Classifier API error → deny with retry guidance. Controlled by tengu_iron_gate_closed GrowthBook gate.

Transcript Too Long

Falls back to normal permission prompting (interactive mode) or throws AbortError (headless mode).

Fail Open (gate off)

If the iron-gate feature is off, a classifier error falls back to normal permission handling instead of denying.

7. Shadowed Rule Detection

A permission rule can be unreachable — written correctly but never evaluated because a more general rule fires first. shadowedRuleDetection.ts detects and reports these conflicts.

Deny-shadowed

A tool-wide deny rule (e.g., Bash in deny list) makes any specific allow rule (e.g., Bash(ls:*)) unreachable — the deny fires first.

Ask-shadowed

A tool-wide ask rule (e.g., Bash in ask list) always prompts the user, making a specific allow rule (e.g., Bash(ls:*)) unreachable. Exception: if the ask rule is from personal settings and sandbox auto-allow is on, no warning is issued.

// Example: allow rule is deny-shadowed
permissions.deny  = ["Bash"]      // ← fires first, blocks everything
permissions.allow = ["Bash(ls:*)"] // ← never reached → shadowed!

// Fix: remove the tool-wide "Bash" from deny,
// or add more specific deny rules instead.

8. Permission Explainer

When a permission prompt appears, the permissionExplainer.ts makes a side API call (using the current model) to generate a human-readable explanation of what the command does, why Claude wants to run it, and the risk level.

// Structured output schema returned by the explainer model
{
  explanation: "What this command does (1-2 sentences)",
  reasoning:   "I need to check the file contents",  // starts with "I"
  risk:        "Modifies files outside the working directory",
  riskLevel:   "HIGH"  // LOW | MEDIUM | HIGH
}
  • Uses sideQuery() — a separate API call that does NOT count toward the main session's token totals.
  • The model is forced to use a specific tool (explain_command) for guaranteed structured output.
  • Can be disabled via permissionExplainerEnabled: false in global config.
  • Includes up to 1,000 chars of recent conversation context so the "why" answer is grounded in what Claude was doing.

9. Key Takeaways

  • Deny rules are always checked first — before bypass mode, before allow rules, before tool-specific logic. They are the only unconditional block.
  • bypassPermissions is not truly unconditional — it cannot bypass deny rules, content-specific ask rules, safety checks on protected paths, or tools that require user interaction.
  • Safety-check paths are bypass-immune by design.git/, .claude/, .vscode/, and shell config files always prompt, preventing silent config corruption.
  • Rule specificity matters for shadowing — a tool-wide deny or ask rule silently makes all specific allow rules for that tool unreachable. The shadow detector warns about this.
  • Auto mode has layered fast paths — safe allowlist → acceptEdits check → classifier. Only the last step costs an API call.
  • Classifier denial limits prevent infinite loops — after 3 consecutive or 20 total classifier denials, the system falls back to prompting a human.
  • Rule sources form a layered override system — enterprise policy can lock down rules completely with allowManagedPermissionRulesOnly, preventing any user overrides.
  • Legacy rule names are auto-migrated — rules using old tool names (Task, KillShell) are transparently rewritten to current canonical names at parse time.

10. Quiz

Test your understanding. Select the best answer for each question.

Q1. A user has Bash in their deny list AND Bash(ls:*) in their allow list. What happens when Claude tries to run ls -la?

Q2. In bypassPermissions mode, which of these WILL still prompt the user?

Q3. The auto mode classifier has made 3 consecutive denials this session. What happens on the 4th ambiguous action?

Q4. A rule string "Bash(npm:*)" uses what rule type?

Q5. What does the dontAsk permission mode do differently from bypassPermissions?