How Claude Code decides whether to run a tool: from deny rules to AI classifiers
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:
Tool executes immediately
Tool is blocked; Claude is told why
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).
The pipeline is implemented in hasPermissionsToUseToolInner() in permissions.ts. Steps are numbered to match the inline comments in the source.
Each numbered step corresponds directly to the // 1a, 1b, 1c... comments in permissions.ts, making the source self-documenting.
getDenyRuleForTool() checks if the tool itself (e.g., "Bash" with no content) appears in any deny list. Immediate deny if found.checkPermissions.checkPermissions(): Each tool implements its own logic (e.g., Bash checks subcommand prefix rules, FileWrite checks the working directory boundary).checkPermissions returns deny, stop immediately.bypassPermissions cannot override.Bash(npm publish:*) that explicitly asks for a command pattern is respected even in bypass mode..git/, .claude/, .vscode/, shell config files always requires user approval, regardless of mode.bypassPermissions (or plan with bypass available), everything that passed step 1 is allowed.toolAlwaysAllowedRule() checks if the entire tool is in the allow list (e.g., "Bash" with no content).ask result is transformed: dontAsk → deny; auto → classifier; headless → hooks → auto-deny.The mode controls which pipeline branch executes for any ask result. It is stored in toolPermissionContext.mode.
Standard mode. Shows a prompt to the user for every unrecognized tool use.
Read-only planning phase. Write tools are blocked until user exits plan mode.
Auto-approves file edits inside the working directory. Shell commands still prompt.
Skips all prompts (except deny rules, content ask rules, safety checks, requiresUserInteraction). Requires gated access.
Converts every ask into a deny. Claude is told it cannot use the tool.
Routes ask decisions through an AI classifier instead of a human prompt. Feature-flagged via TRANSCRIPT_CLASSIFIER.
PermissionMode.tsconst 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.
Bash(npm publish:*) in the ask list always prompts..git/, .claude/, .vscode/, shell configs always prompts — even in bypass mode.Every rule is a string of the form ToolName or ToolName(content), stored in allow/deny/ask lists. Parsing is handled by permissionRuleParser.ts.
// 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
shellRuleMatching.ts)Full string equality after trimming. Bash(npm install)
Ends with :*. The part before : must be a prefix of the command. Bash(npm:*) matches npm install, npm run build, etc.
Contains unescaped *. Converted to a full regex with dotAll flag. Bash(git * --dry-run)
*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.
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) } }
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, }
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) |
{
"permissions": {
"allow": ["Bash(npm:*)", "Bash(git status)"],
"deny": ["WebFetch"],
"ask": ["Bash(npm publish:*)"]
}
}
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.
.git/ or shell configs — never auto-approved even by the classifier.acceptEdits mode (file edits inside CWD), allow immediately without calling the classifier.classifierDecision.ts.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 ])
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).
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 API error → deny with retry guidance. Controlled by tengu_iron_gate_closed GrowthBook gate.
Falls back to normal permission prompting (interactive mode) or throws AbortError (headless mode).
If the iron-gate feature is off, a classifier error falls back to normal permission handling instead of denying.
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.
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.
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.
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 }
sideQuery() — a separate API call that does NOT count toward the main session's token totals.explain_command) for guaranteed structured output.permissionExplainerEnabled: false in global config..git/, .claude/, .vscode/, and shell config files always prompt, preventing silent config corruption.allowManagedPermissionRulesOnly, preventing any user overrides.Task, KillShell) are transparently rewritten to current canonical names at parse time.Test your understanding. Select the best answer for each question.
Bash in their deny list AND Bash(ls:*) in their allow list. What happens when Claude tries to run ls -la?bypassPermissions mode, which of these WILL still prompt the user?"Bash(npm:*)" uses what rule type?dontAsk permission mode do differently from bypassPermissions?