Claude Code’s source code leaked. Setting aside the surveillance concerns and inevitable spaghetti of any real codebase, it’s a genuinely well-designed harness.
I’ve been digging through it, picking out patterns worth understanding. This is one of them: the permission system - multiple layers of guardrails between every tool call and execution.
The Problem
AI agents will take actions that users don’t allow, that’s why we need a permission system. Claude Code layers multiple permission checks, with each layer following a short-circuit design.
Permission Decisions
Every permission check returns one of three results: allow, deny, and ask. allow and deny exits immediately, only ask continues down the pipeline.
type PermissionDecision =
| { behavior: 'allow', updatedInput?, decisionReason? }
| { behavior: 'deny', message, decisionReason }
| { behavior: 'ask', message, suggestions?, decisionReason? }
Permission Modes
Modes determine the default behavior.
default- Prompt for everything (normal interactive use)acceptEdits- Auto-accept file edits in working directoryplan- Read-only, no tool executionbypassPermissions- Allow everything except safety checks (dangerous)dontAsk- Convert all asks to denies (fail-closed automation)auto- AI classifier decides (hands-free operation)
Permission Sources
Rules come from 7 sources with strict precedence. A rule from a higher source overrides conflicting rules from lower sources.
type PermissionRuleSource =
| 'cliArg' // --permission-mode flag
| 'command' // /command configuration
| 'session' // Current session only
| 'policySettings' // Enterprise policy
| 'flagSettings' // Enterprise feature flags
| 'projectSettings' // .claude/settings.json in project
| 'userSettings' // ~/.claude/settings.json
Permission Flow
Every tool call flows through this pipeline:
┌────────────────────────────────────────────────────────────┐
│ 1. RULE-BASED CHECKS │
│ • Tool denied? → deny │
│ • Tool has ask rule? → ask │
│ • Tool's own checkPermissions() → deny/allow/ask │
│ • Safety checks (.git/, .claude/) → ask │
│ • Tool allowed? → allow │
└────────────────────────────────────────────────────────────┘
↓ (if ask)
┌────────────────────────────────────────────────────────────┐
│ 2. MODE-BASED CHECKS │
│ • bypassPermissions mode? → allow │
│ • dontAsk mode? → deny │
│ • default mode? → ask │
│ • acceptEdits mode? → ask (unless file edit) │
│ • auto mode? → Run classifier │
└────────────────────────────────────────────────────────────┘
↓ (if ask in auto mode)
┌────────────────────────────────────────────────────────────┐
│ 3. AUTO MODE CLASSIFIER │
│ • Would acceptEdits allow? → allow (fast path) │
│ • Tool on safe allowlist? → allow (fast path) │
│ • Run YOLO classifier → allow/deny/ask │
└────────────────────────────────────────────────────────────┘
↓ (if still ask)
┌────────────────────────────────────────────────────────────┐
│ 4. USER PROMPT │
│ • Show permission dialog │
│ • User decides: Allow Once / Always / Deny │
│ • "Always" creates persistent rule │
└────────────────────────────────────────────────────────────┘
The core logic lives in hasPermissionsToUseToolInner():
async function hasPermissionsToUseToolInner(
tool: Tool,
input: { [key: string]: unknown },
context: ToolUseContext,
): Promise<PermissionDecision> {
// 1a. Tool-level deny rule
const denyRule = getDenyRuleForTool(context, tool)
if (denyRule) return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule } }
// 1b. Tool-level ask rule
const askRule = getAskRuleForTool(context, tool)
if (askRule) return { behavior: 'ask', decisionReason: { type: 'rule', rule: askRule } }
// 1c. Tool's own permission check
const toolResult = await tool.checkPermissions(parsedInput, context)
if (toolResult?.behavior === 'deny') return toolResult
if (toolResult?.behavior === 'ask') return toolResult
// 2a. Bypass mode
if (mode === 'bypassPermissions') return { behavior: 'allow' }
// 2b. Tool-level allow rule
const allowRule = toolAlwaysAllowedRule(context, tool)
if (allowRule) return { behavior: 'allow', decisionReason: { type: 'rule', rule: allowRule } }
// 3. Convert passthrough to ask
return { behavior: 'ask', message: createPermissionRequestMessage(tool.name) }
}
The wrapper hasPermissionsToUseTool() handles post-processing:
export const hasPermissionsToUseTool = async (tool, input, context) => {
const result = await hasPermissionsToUseToolInner(tool, input, context)
// Early exit for allow/deny
if (result.behavior === 'allow') return result
// Mode transformations for 'ask'
if (result.behavior === 'ask') {
if (mode === 'dontAsk') return { behavior: 'deny' }
if (mode === 'auto') {
// classifyYoloAction(...)
}
}
return result
}
Permission Rules
Rules are the configuration layer collected from multiple sources, from ~/.claude/settings.json, {project}/.claude/settings.json and etc.
{
"permissions": {
"allow": [
"Bash(xargs awk:*)",
"Bash(uv sync:*)",
"Bash(git *)",
...
]
"deny": []
}
}
Bash(git *) matches git status, git commit, etc. Without a matcher, only tool-name-level matching works.
Auto Mode Classifier
Auto mode is not released yet at this moment. The classifier enables hands-free operation. Instead of showing a permission dialog, it utilize LLM’s capability to decide if the action is safe.
Two-Stage Classification
The classifier uses a cascading approach: try cheap first, escalate to expensive only when needed.
Most of the cases are common cases. Fast stage is optimized for the common case, safe operations. With only 64 tokens and a stop sequence, it’s fast and cheap. If something looks safe, it returns allow immediately.
Thinking Stage only runs when Fast Stage says block. This is the expensive path - 4096 tokens, chain-of-thought reasoning, deeper analysis. It double-checks whether the block is justified or if there’s context that makes it safe.
Two stages use different prompt, fast stage:
<transcript>
[recent conversation history]
[tool call being classified]
</transcript>
Err on the side of blocking. <block> immediately.
While thinking stage has some more:
<transcript>
[recent conversation history]
[tool call being classified]
</transcript>
Review the classification process and follow it carefully, making sure
you deny actions that should be blocked. As a reminder, explicit (not
suggestive or implicit) user confirmation is required to override blocks.
Use <thinking> before responding with <block>.
Caveats
Some details that make this work in practice.
Safety Checks Are Bypass-Immune
Even in bypassPermissions mode, safety checks for sensitive paths (.git/, .claude/, shell configs) still prompt. These can’t be disabled by mode - only explicit rules can override them.
Brief
3 permission results, 6 modes, 14 stages permission checks—Claude Code has a complex permission system.
Auto mode is something not released yet, but indeed an interesting concept. It solves a real problem: we need something between blindly allowing everything and reviewing everything. Meanwhile, Auto mode has a strong purpose of serving their “KARIOS” mode (background daemon).
There is not much to comment. But it is good to have a mental model about the mechanism of the permission system.
Related
Learn From Claude Code: MCP Integration
by a Developer
Apr 2026
by a Developer
agent
ai
Anthropic invented MCP a year ago. Now they're experimenting with skill-ifying it. Here's what the leaked source reveals about the pivot.
Learn From Claude Code: Memory
by a Developer
Apr 2026
by a Developer
agent
ai
Learning Claude Code's memory system by inspecting its leaked source code.
Learn From Claude Code: Query Engine
by a Developer
Apr 2026
by a Developer
agent
ai
The query engine isn't a state machine for academic reasons. It's a recovery coordinator - every continue handles something that went wrong.
Learn From Claude Code: Tool System
by a Developer
Apr 2026
by a Developer
agent
ai
Claude Code has 45+ tools. OpenClaw preached minimal tools. Both work. What does that tell us?