The Permission System: Walking the Tightrope Between Autonomy and Safety

A deep dive into Claude Code's multi-layered permission evaluation — from Hook pre-screening to automated classifiers to rule matching, understanding the engineering behind AI tool safety

The Problem

Would you let an AI run rm -rf / without asking? Probably not. What about git push? The answer is less clear-cut — some people are perfectly fine pushing to their own development branch, while others insist on confirmation before pushing to main. And what about cat package.json? If every file read required clicking "Allow," the user experience would be maddening.

Every operation carries different risk, so where should the permission boundary be drawn?

This isn't a new problem. Unix's rwx permission model, Android's runtime permissions, the browser's same-origin policy — every platform strikes a balance between capability and security. But AI coding tools face more complex challenges:

  1. Vast action space: Beyond just reading and writing files, there's command execution, network requests, and calls to external services
  2. Risk assessment requires semantic understanding: rm -rf node_modules and rm -rf / look syntactically similar, but carry wildly different levels of risk
  3. Conflicting user expectations: Users want both "automation" and "safety," both "speed" and "ask me first"
  4. Multi-role collaboration: The main agent, coordinator workers, and swarm workers have entirely different permission requirements

Claude Code's permission system tackles this problem with an elegantly layered evaluation pipeline. This article provides a complete analysis of its design at the source code level.


Permission Modes Overview

Before diving into the evaluation pipeline, let's look at the permission modes that Claude Code defines. These modes determine the system's "default posture."

Mode Definitions

Permission modes are defined in src/types/permissions.ts:

TypeScript
1// src/types/permissions.ts, lines 16-38
2export const EXTERNAL_PERMISSION_MODES = [
3 'acceptEdits',
4 'bypassPermissions',
5 'default',
6 'dontAsk',
7 'plan',
8] as const
9
10export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]
11
12export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
13export type PermissionMode = InternalPermissionMode
14
15export const INTERNAL_PERMISSION_MODES = [
16 ...EXTERNAL_PERMISSION_MODES,
17 ...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
18] as const satisfies readonly PermissionMode[]

Note that the auto mode is gated by feature('TRANSCRIPT_CLASSIFIER') — this is an internal-only feature flag. The bubble mode is entirely internal and never appears in user-configurable options.

Mode Behavior Matrix

The specific behavior of each mode is configured in src/utils/permissions/PermissionMode.ts:

TypeScript
1// src/utils/permissions/PermissionMode.ts, lines 42-91
2const PERMISSION_MODE_CONFIG: Partial<
3 Record<PermissionMode, PermissionModeConfig>
4> = {
5 default: {
6 title: 'Default',
7 shortTitle: 'Default',
8 symbol: '',
9 color: 'text',
10 external: 'default',
11 },
12 plan: {
13 title: 'Plan Mode',
14 shortTitle: 'Plan',
15 symbol: PAUSE_ICON,
16 color: 'planMode',
17 external: 'plan',
18 },
19 acceptEdits: {
20 title: 'Accept edits',
21 shortTitle: 'Accept',
22 symbol: '⏵⏵',
23 color: 'autoAccept',
24 external: 'acceptEdits',
25 },
26 bypassPermissions: {
27 title: 'Bypass Permissions',
28 shortTitle: 'Bypass',
29 symbol: '⏵⏵',
30 color: 'error',
31 external: 'bypassPermissions',
32 },
33 // ...
34}

Here's a summary of each mode's semantics:

ModeSemanticsTypical Use Case
defaultAll non-read-only operations require user confirmationDay-to-day interactive use
planOnly generates plans, does not execute modificationsCode review, architecture discussions
acceptEditsAutomatically approves file edits, but Bash commands still require confirmationTrusting the model's editing capabilities
bypassPermissionsSkips almost all permission checksCI/CD environments, fully trusted scenarios
dontAskConverts all ask results to denyNon-interactive environments
autoUses an AI classifier to automatically assess riskInternal power users
...

This spectrum design is elegant: from "full trust" to "zero trust," users can position themselves wherever suits their scenario. But modes are only the first layer — they determine the "default posture," while the actual permission decisions must pass through multiple layers of evaluation.


The Multi-Layered Evaluation Pipeline

The core entry point for permission evaluation is the hasPermissionsToUseTool function, defined in src/utils/permissions/permissions.ts. The entire pipeline can be divided into two major phases: static rule evaluation (synchronous, fast) and dynamic interactive evaluation (asynchronous, potentially involving user interaction).

Phase 1: Static Rule Evaluation (hasPermissionsToUseToolInner)

TypeScript
1// src/utils/permissions/permissions.ts, lines 1158-1319
2async function hasPermissionsToUseToolInner(
3 tool: Tool,
4 input: { [key: string]: unknown },
5 context: ToolUseContext,
6): Promise<PermissionDecision> {
7 if (context.abortController.signal.aborted) {
8 throw new AbortError()
9 }
10
11 let appState = context.getAppState()
12
13 // 1. Check if the tool is denied
14 // 1a. Entire tool is denied
15 const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
16 if (denyRule) {
17 return {
18 behavior: 'deny',
19 decisionReason: { type: 'rule', rule: denyRule },
20 message: `Permission to use ${tool.name} has been denied.`,
21 }
22 }
23 // ...
24}

The complete static evaluation flow, ordered by priority:

...

The ordering of this sequence is intentional:

Deny takes priority: Regardless of the current mode, deny rules are always checked first. This guarantees a security baseline — even in bypassPermissions mode, explicit deny rules still take effect.

Safety checks cannot be bypassed: Steps 1f and 1g ensure that certain safety checks cannot be circumvented even in bypassPermissions mode. Modifications to .git/, .claude/, and shell configuration files always require confirmation. This embodies the "trust but verify" philosophy — you trust the AI's capabilities, but some operations have consequences too severe to skip.

Mode check sits in the middle: The bypassPermissions mode check comes after deny rules and safety checks, but before allow rules. This means bypass mode "skips normal permissions," not "skips everything."

Passthrough as fallback: If a tool's own checkPermissions returns passthrough (meaning "I don't have an opinion on this permission decision"), the system converts it to ask, ensuring no operation is silently permitted.

Phase 2: Dynamic Interactive Evaluation (useCanUseTool and beyond)

When Phase 1 returns ask, control flow enters the useCanUseTool hook. This is where the real complexity lives:

TypeScript
1// src/hooks/useCanUseTool.tsx, lines 28-33
2function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) {
3 // ...
4 return async (tool, input, toolUseContext, assistantMessage, toolUseID) =>
5 new Promise(resolve => {
6 const ctx = createPermissionContext(/* ... */)
7 // ...
8 const result = await hasPermissionsToUseTool(tool, input, toolUseContext, ...)
9 // Branch based on result.behavior
10 })
11}

When result.behavior === 'ask', the system enters a multi-contender mode — five sources compete simultaneously to be the first to make a decision: Hooks, the classifier, the user, Bridge (remote via claude.ai), and Channels (Telegram, etc.). We'll explore this in detail in the following sections.


Deep Dive into the Rule System

Three Types of Rules

Permission rules come in three types, each with independent source tracking:

TypeScript
1// src/Tool.ts, lines 123-148
2export type ToolPermissionContext = DeepImmutable<{
3 mode: PermissionMode
4 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
5 alwaysAllowRules: ToolPermissionRulesBySource
6 alwaysDenyRules: ToolPermissionRulesBySource
7 alwaysAskRules: ToolPermissionRulesBySource
8 isBypassPermissionsModeAvailable: boolean
9 isAutoModeAvailable?: boolean
10 strippedDangerousRules?: ToolPermissionRulesBySource
11 shouldAvoidPermissionPrompts?: boolean
12 awaitAutomatedChecksBeforeDialog?: boolean
13 prePlanMode?: PermissionMode
14}>

ToolPermissionRulesBySource is essentially Record<PermissionRuleSource, string[]>. Each source maps to a set of rule strings. The sources are defined in src/utils/permissions/permissions.ts:

TypeScript
1// src/utils/permissions/permissions.ts, lines 109-114
2const PERMISSION_RULE_SOURCES = [
3 ...SETTING_SOURCES, // localSettings, userSettings, projectSettings, policySettings, flagSettings
4 'cliArg', // Command-line arguments
5 'command', // Command level
6 'session', // Session level
7] as const satisfies readonly PermissionRuleSource[]

The seven sources, ordered from highest to lowest priority:

  1. policySettings: Enterprise policies (admin-configured, cannot be overridden)
  2. flagSettings: Feature flags
  3. projectSettings: Project-level configuration (.claude/settings.json)
  4. localSettings: Local configuration (.claude/settings.local.json)
  5. userSettings: User global configuration (~/.claude/settings.json)
  6. cliArg: Command-line arguments
  7. session: Temporary choices made by the user during the current session

Rule Matching Mechanism

The matching logic supports two levels of granularity:

TypeScript
1// src/utils/permissions/permissions.ts, lines 238-269
2function toolMatchesRule(
3 tool: Pick<Tool, 'name' | 'mcpInfo'>,
4 rule: PermissionRule,
5): boolean {
6 // Rule must not have content to match the entire tool
7 if (rule.ruleValue.ruleContent !== undefined) {
8 return false
9 }
10
11 const nameForRuleMatch = getToolNameForPermissionCheck(tool)
12
13 // Direct tool name match
14 if (rule.ruleValue.toolName === nameForRuleMatch) {
15 return true
16 }
17
18 // MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1"
19 const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
20 const toolInfo = mcpInfoFromString(nameForRuleMatch)
21
22 return (
23 ruleInfo !== null &&
24 toolInfo !== null &&
25 (ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
26 ruleInfo.serverName === toolInfo.serverName
27 )
28}

Tool-level matching: The rule "Bash" matches all Bash tool invocations. Content-level matching: The rule "Bash(prefix:npm install)" only matches Bash commands that start with npm install. MCP server-level matching: The rule "mcp__server1" matches all tools under that server.

Content-level matching is implemented through getRuleByContentsForTool:

TypeScript
1// src/utils/permissions/permissions.ts, lines 362-389
2export function getRuleByContentsForToolName(
3 context: ToolPermissionContext,
4 toolName: string,
5 behavior: PermissionBehavior,
6): Map<string, PermissionRule> {
7 const ruleByContents = new Map<string, PermissionRule>()
8 let rules: PermissionRule[] = []
9 switch (behavior) {
10 case 'allow': rules = getAllowRules(context); break
11 case 'deny': rules = getDenyRules(context); break
12 case 'ask': rules = getAskRules(context); break
13 }
14 for (const rule of rules) {
15 if (
16 rule.ruleValue.toolName === toolName &&
17 rule.ruleValue.ruleContent !== undefined &&
18 rule.ruleBehavior === behavior
19 ) {
20 ruleByContents.set(rule.ruleValue.ruleContent, rule)
21 }
22 }
23 return ruleByContents
24}

The elegance of this design lies in the fact that it's not a simple "allow all / deny all" approach. Instead, it lets users set different permission levels for different operations on the same tool. You can allow Bash(prefix:npm test) but deny Bash(prefix:npm publish), or allow Bash(prefix:git status) but require confirmation for Bash(prefix:git push).

Rule Source Tracking Example

At runtime, a rule's lifecycle might look like this:

流程
User selects "Always allow for this project" in the interactive dialog
Generates PermissionUpdate: { type: 'addRules', destination: 'projectSettings', ... }
persistPermissionUpdates writes to .claude/settings.json
applyPermissionUpdates updates the in-memory ToolPermissionContext
On next match, the rule is found via the projectSettings source

The DeepImmutable Design of ToolPermissionContext

Take a closer look at the type definition of ToolPermissionContext:

TypeScript
1// src/Tool.ts, line 123
2export type ToolPermissionContext = DeepImmutable<{
3 mode: PermissionMode
4 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
5 alwaysAllowRules: ToolPermissionRulesBySource
6 alwaysDenyRules: ToolPermissionRulesBySource
7 alwaysAskRules: ToolPermissionRulesBySource
8 // ...
9}>

DeepImmutable is a recursive type utility that marks all levels of an object as readonly. This is not an accidental design choice — it addresses one of the most dangerous classes of bugs in a permission system: runtime permission state being accidentally modified.

Imagine this scenario:

TypeScript
1// Dangerous mutable design (NOT used by Claude Code)
2const context = getToolPermissionContext()
3context.mode = 'bypassPermissions' // Directly modified the global permission mode!

With DeepImmutable, any code attempting to modify the permission context will trigger a compile-time error. To change permission state, you must create a new object through setToolPermissionContext — this ensures that permission state changes are trackable and atomic.

The initialization also uses a clear empty-state factory function:

TypeScript
1// src/Tool.ts, lines 140-148
2export const getEmptyToolPermissionContext: () => ToolPermissionContext =
3 () => ({
4 mode: 'default',
5 additionalWorkingDirectories: new Map(),
6 alwaysAllowRules: {},
7 alwaysDenyRules: {},
8 alwaysAskRules: {},
9 isBypassPermissionsModeAvailable: false,
10 })

The default mode is default, all rules are empty, and bypass is unavailable. This is a "secure by default" design — the system starts in its most restrictive state and requires explicit relaxation.


Filesystem Scoping

The permission system doesn't just check "what tools you can use" — it also checks "which files you can operate on." additionalWorkingDirectories is a key component of this mechanism.

Working Directory Boundaries

By default, Claude Code's file operations are restricted to the current working directory (cwd). But in practice, projects can span multiple directories — multiple subprojects in a monorepo, shared library directories, and so on. additionalWorkingDirectories allows users to extend this boundary.

Dangerous File and Directory Protection

Even within the working directory, certain files receive additional protection:

TypeScript
1// src/utils/permissions/filesystem.ts, lines 57-79
2export const DANGEROUS_FILES = [
3 '.gitconfig',
4 '.gitmodules',
5 '.bashrc',
6 '.bash_profile',
7 '.zshrc',
8 '.zprofile',
9 '.profile',
10 '.ripgreprc',
11 '.mcp.json',
12 '.claude.json',
13] as const
14
15export const DANGEROUS_DIRECTORIES = [
16 '.git',
17 '.vscode',
18 '.idea',
19 '.claude',
20] as const

These files share a common characteristic: modifying them can lead to code execution or data exfiltration. A modified .bashrc means malicious code runs the next time a terminal is opened; a modified .gitconfig could lead to credential leakage; a modified .mcp.json could introduce a malicious MCP server.

Even in bypassPermissions or auto mode, modifications to these paths must go through user confirmation (the safety check in step 1g). This is the only non-configurable hard constraint in the entire permission system.


PermissionContext and the ResolveOnce Atomicity Pattern

When permission evaluation enters the interactive phase, the system faces a classic concurrency problem: multiple asynchronous sources may simultaneously make permission decisions. PermissionContext and ResolveOnce are the core mechanisms for solving this problem.

createPermissionContext

createPermissionContext is defined in src/hooks/toolPermission/PermissionContext.ts. It creates a context object that encapsulates all permission operations:

TypeScript
1// src/hooks/toolPermission/PermissionContext.ts, lines 96-347
2function createPermissionContext(
3 tool: ToolType,
4 input: Record<string, unknown>,
5 toolUseContext: ToolUseContext,
6 assistantMessage: AssistantMessage,
7 toolUseID: string,
8 setToolPermissionContext: (context: ToolPermissionContext) => void,
9 queueOps?: PermissionQueueOps,
10) {
11 const ctx = {
12 tool,
13 input,
14 toolUseContext,
15 assistantMessage,
16 messageId: assistantMessage.message.id,
17 toolUseID,
18 logDecision(args, opts?) { /* ... */ },
19 logCancelled() { /* ... */ },
20 async persistPermissions(updates) { /* ... */ },
21 resolveIfAborted(resolve) { /* ... */ },
22 cancelAndAbort(feedback?, isAbort?, contentBlocks?) { /* ... */ },
23 async tryClassifier(pendingClassifierCheck, updatedInput) { /* ... */ },
24 async runHooks(permissionMode, suggestions, updatedInput?, startMs?) { /* ... */ },
25 buildAllow(updatedInput, opts?) { /* ... */ },
26 buildDeny(message, decisionReason) { /* ... */ },
27 async handleUserAllow(updatedInput, permissionUpdates, ...) { /* ... */ },
28 async handleHookAllow(finalInput, permissionUpdates, ...) { /* ... */ },
29 pushToQueue(item) { queueOps?.push(item) },
30 removeFromQueue() { queueOps?.remove(toolUseID) },
31 updateQueueItem(patch) { queueOps?.update(toolUseID, patch) },
32 }
33 return Object.freeze(ctx)
34}

Note the last line, Object.freeze(ctx) — the context object is frozen and cannot be modified. This is consistent with the DeepImmutable design philosophy: permission-related objects should be immutable.

ResolveOnce: Atomic Decision Guarantee

When multiple sources compete to make a permission decision, the most dangerous scenario is a "double decision" — a Hook approves the operation while the user simultaneously clicks "Deny." If both decisions are executed, the system state becomes inconsistent.

ResolveOnce solves this problem with a clean atomicity pattern:

TypeScript
1// src/hooks/toolPermission/PermissionContext.ts, lines 63-94
2type ResolveOnce<T> = {
3 resolve(value: T): void
4 isResolved(): boolean
5 claim(): boolean
6}
7
8function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
9 let claimed = false
10 let delivered = false
11 return {
12 resolve(value: T) {
13 if (delivered) return
14 delivered = true
15 claimed = true
16 resolve(value)
17 },
18 isResolved() {
19 return claimed
20 },
21 claim() {
22 if (claimed) return false
23 claimed = true
24 return true
25 },
26 }
27}

There are two flags here, claimed and delivered, and the distinction between them matters:

  • claimed: Indicates "someone has claimed the decision authority." Once set, subsequent claim() calls from other contenders return false.
  • delivered: Indicates "the Promise has been resolved." Prevents resolve from being called multiple times.

Why two flags instead of one? Because in asynchronous callbacks, there may be await operations between claim() and resolve():

TypeScript
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 159-161
2async onAllow(updatedInput, permissionUpdates, feedback?, contentBlocks?) {
3 if (!claim()) return // Atomic check: if another source already decided, exit immediately
4 // ↑ Between here and resolveOnce below, there may be awaits
5 resolveOnce(
6 await ctx.handleUserAllow(updatedInput, permissionUpdates, ...)
7 )
8}

If only the delivered flag were used, two callbacks could both pass the !delivered check and then both execute await ctx.handleUserAllow, causing double processing. The atomic claim() check closes this window.


Three Permission Handlers

The interactive phase of the permission system is managed by three specialized handlers, each corresponding to a different runtime scenario.

interactiveHandler: Interactive Handling for the Main Agent

This is the most complex handler because it needs to coordinate the most competing sources. It's defined in src/hooks/toolPermission/handlers/interactiveHandler.ts.

TypeScript
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 57-60
2function handleInteractivePermission(
3 params: InteractivePermissionParams,
4 resolve: (decision: PermissionDecision) => void,
5): void {

Note the return type is void, not Promise. This function doesn't wait for a decision to complete — it sets up all callbacks and returns immediately. The decision happens asynchronously via callbacks.

The competing sources include:

  1. User interaction (onAllow / onReject / onAbort)
  2. Async Hook execution (runHooks)
  3. Bash classifier (executeAsyncClassifierCheck)
  4. Bridge remote response (approval/denial from claude.ai)
  5. Channel response (approval/denial from Telegram/iMessage, etc.)
...

One noteworthy detail is the classifier's user interaction protection mechanism:

TypeScript
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 108-122
2onUserInteraction() {
3 // Grace period: ignore interactions in the first 200ms to prevent
4 // accidental keypresses from canceling the classifier prematurely
5 const GRACE_PERIOD_MS = 200
6 if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) {
7 return
8 }
9 userInteracted = true
10 clearClassifierChecking(ctx.toolUseID)
11 clearClassifierIndicator()
12},

When the user starts interacting with the permission dialog (pressing arrow keys, Tab, or typing), the classifier's auto-approval is canceled. But there's a 200ms grace period — this prevents accidental keypresses when the dialog first appears from prematurely canceling the classifier. This kind of fine-tuned UX polish reflects the engineering team's experience.

coordinatorHandler: Serial Pre-screening for Coordinator Workers

The coordinator worker's (coordinator sub-agent) handling logic is simpler. Since it runs within the main agent's context but can't directly display UI, it first runs automated checks serially, and only falls back to an interactive dialog if none of them can make a decision:

TypeScript
1// src/hooks/toolPermission/handlers/coordinatorHandler.ts, lines 26-62
2async function handleCoordinatorPermission(
3 params: CoordinatorPermissionParams,
4): Promise<PermissionDecision | null> {
5 const { ctx, updatedInput, suggestions, permissionMode } = params
6
7 try {
8 // 1. Try permission hooks first (fast, local)
9 const hookResult = await ctx.runHooks(
10 permissionMode, suggestions, updatedInput,
11 )
12 if (hookResult) return hookResult
13
14 // 2. Try classifier (slow, inference -- bash only)
15 const classifierResult = feature('BASH_CLASSIFIER')
16 ? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
17 : null
18 if (classifierResult) return classifierResult
19 } catch (error) {
20 if (error instanceof Error) {
21 logError(error)
22 } else {
23 logError(new Error(`Automated permission check failed: ${String(error)}`))
24 }
25 }
26
27 // 3. Neither resolved -- fall through to dialog.
28 return null
29}

The key difference: the interactive handler lets Hooks and the classifier race in parallel with the user; the coordinator handler runs them serially before displaying the dialog. This is because the coordinator worker's awaitAutomatedChecksBeforeDialog flag is true — its design philosophy is "let automated systems try to resolve it first; only bother the user if they can't."

swarmWorkerHandler: Mailbox Forwarding for Swarm Workers

Swarm workers (cluster work nodes) are the most unique role — they can't directly interact with users or display permission dialogs. Their strategy is: try the classifier for auto-approval first, and if that fails, forward the permission request to the leader:

TypeScript
1// src/hooks/toolPermission/handlers/swarmWorkerHandler.ts, lines 40-156
2async function handleSwarmWorkerPermission(
3 params: SwarmWorkerPermissionParams,
4): Promise<PermissionDecision | null> {
5 if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
6 return null // Not in a swarm environment, return null to fall back to interactive handling
7 }
8
9 // Try classifier auto-approval first
10 const classifierResult = feature('BASH_CLASSIFIER')
11 ? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
12 : null
13 if (classifierResult) return classifierResult
14
15 // Forward permission request to the leader
16 const decision = await new Promise<PermissionDecision>(resolve => {
17 const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
18
19 const request = createPermissionRequest({
20 toolName: ctx.tool.name,
21 toolUseId: ctx.toolUseID,
22 input: ctx.input,
23 description,
24 permissionSuggestions: suggestions,
25 })
26
27 // Register callback first, then send request — avoids race conditions
28 registerPermissionCallback({
29 requestId: request.id,
30 toolUseId: ctx.toolUseID,
31 async onAllow(allowedInput, permissionUpdates, feedback?, contentBlocks?) {
32 if (!claim()) return
33 // ...
34 },
35 onReject(feedback?, contentBlocks?) {
36 if (!claim()) return
37 // ...
38 },
39 })
40
41 // Send the request
42 void sendPermissionRequestViaMailbox(request)
43
44 // Display a waiting indicator
45 ctx.toolUseContext.setAppState(prev => ({
46 ...prev,
47 pendingWorkerRequest: { toolName: ctx.tool.name, toolUseId: ctx.toolUseID, description },
48 }))
49
50 // Handle abort signal
51 ctx.toolUseContext.abortController.signal.addEventListener('abort', () => {
52 if (!claim()) return
53 resolveOnce(ctx.cancelAndAbort(undefined, true))
54 }, { once: true })
55 })
56
57 return decision
58}

There's an elegant race condition guard here: register the callback first, then send the request. If the order were reversed, the following scenario could occur:

  1. Worker sends permission request to the leader
  2. Leader responds instantly
  3. The response arrives before the callback is registered
  4. The response is dropped

By registering first and sending second, even if the leader's response arrives before sendPermissionRequestViaMailbox returns, the callback is already in place to handle it.

Handler Dispatch Logic

In useCanUseTool.tsx, the three handlers are dispatched in the following order:

TypeScript
1// src/hooks/useCanUseTool.tsx, lines 94-168
2case "ask": {
3 // 1. Coordinator pre-check (if awaitAutomatedChecksBeforeDialog)
4 if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
5 const coordinatorDecision = await handleCoordinatorPermission({...})
6 if (coordinatorDecision) {
7 resolve(coordinatorDecision)
8 return
9 }
10 }
11
12 // 2. Swarm worker handling (if in swarm environment)
13 const swarmDecision = await handleSwarmWorkerPermission({...})
14 if (swarmDecision) {
15 resolve(swarmDecision)
16 return
17 }
18
19 // 3. Interactive handling (fallback for the main agent)
20 handleInteractivePermission({
21 ctx, description, result,
22 awaitAutomatedChecksBeforeDialog: ...,
23 bridgeCallbacks: ...,
24 channelCallbacks: ...,
25 }, resolve)
26 return
27}

This is a classic chain of responsibility pattern: each handler either returns a decision (non-null) or returns null to pass control to the next handler. The final handleInteractivePermission serves as the fallback — it can always handle the request (by displaying UI).


Auto Mode and the AI Classifier

auto mode is the most cutting-edge part of the permission system. Rather than simply allowing or denying all operations, it uses an AI classifier to assess the risk of each operation.

Classifier Evaluation Flow

When the mode is auto, hasPermissionsToUseTool goes through a series of fast-path checks before returning ask:

TypeScript
1// src/utils/permissions/permissions.ts, lines 519-648 (simplified)
2if (appState.toolPermissionContext.mode === 'auto') {
3 // Fast path 1: Safety checks cannot be bypassed by the classifier
4 if (result.decisionReason?.type === 'safetyCheck' && !result.decisionReason.classifierApprovable) {
5 return result // Keep as ask
6 }
7
8 // Fast path 2: Tools that require user interaction
9 if (tool.requiresUserInteraction?.()) {
10 return result
11 }
12
13 // Fast path 3: Operations that acceptEdits mode would allow
14 const acceptEditsResult = await tool.checkPermissions(parsedInput, {
15 ...context,
16 getAppState: () => ({
17 ...state,
18 toolPermissionContext: { ...state.toolPermissionContext, mode: 'acceptEdits' },
19 }),
20 })
21 if (acceptEditsResult.behavior === 'allow') {
22 return { behavior: 'allow', ... } // Allow directly, no classifier needed
23 }
24
25 // Fast path 4: Safe tool allowlist
26 if (classifierDecisionModule.isAutoModeAllowlistedTool(tool.name)) {
27 return { behavior: 'allow', ... }
28 }
29
30 // Final: Call the classifier API
31 // ...
32}

This design embodies the concept of "progressive trust":

  1. Hard safety checks can never be bypassed
  2. If acceptEdits mode considers something safe, then auto mode should too — avoiding unnecessary classifier API calls
  3. Allowlisted tools (such as read-only tools) pass through directly
  4. Only operations that genuinely require judgment are sent to the classifier

Consecutive Denial Tracking

auto mode also has a consecutive denial tracking mechanism (denialTracking). When the classifier consecutively denies multiple operations, the system falls back to interactive prompting. This prevents an overly conservative classifier from completely stalling the workflow.


Hook Pre-screening Mechanism

Permission hooks are key to Claude Code's extensibility. Users can configure custom PermissionRequest hooks to add additional logic beyond the standard permission checks.

Hooks' Position in the Pipeline

The timing of hook execution depends on the runtime mode:

  • Interactive mode: Hooks race in parallel with the user dialog (fire-and-forget async)
  • Coordinator mode: Hooks execute serially before the dialog is displayed
  • Swarm mode: Hooks don't participate directly (executed on the leader side)
TypeScript
1// src/hooks/toolPermission/PermissionContext.ts, lines 216-263
2async runHooks(
3 permissionMode, suggestions, updatedInput?, permissionPromptStartTimeMs?,
4): Promise<PermissionDecision | null> {
5 for await (const hookResult of executePermissionRequestHooks(
6 tool.name, toolUseID, input, toolUseContext,
7 permissionMode, suggestions, toolUseContext.abortController.signal,
8 )) {
9 if (hookResult.permissionRequestResult) {
10 const decision = hookResult.permissionRequestResult
11 if (decision.behavior === 'allow') {
12 return await this.handleHookAllow(finalInput, decision.updatedPermissions ?? [], ...)
13 } else if (decision.behavior === 'deny') {
14 // Hooks can also set interrupt: true to abort the entire session
15 if (decision.interrupt) {
16 toolUseContext.abortController.abort()
17 }
18 return this.buildDeny(decision.message || 'Permission denied by hook', ...)
19 }
20 }
21 }
22 return null // No hook made a decision
23}

Hooks can do three things:

  1. Allow (behavior: 'allow'): Skip user confirmation and execute directly. Can include updatedPermissions to persist new rules.
  2. Deny (behavior: 'deny'): Block execution. Can set interrupt: true to abort the entire session.
  3. Make no decision (no return or skip): Let other mechanisms continue processing.

Hook Handling for Headless Agents

For headless agents where shouldAvoidPermissionPrompts is true (background agents running without UI), hooks are the only path to automated approval. If hooks don't make a decision, the operation is automatically denied:

TypeScript
1// src/utils/permissions/permissions.ts, lines 400-470
2async function runPermissionRequestHooksForHeadlessAgent(
3 tool, input, toolUseID, context, permissionMode, suggestions,
4): Promise<PermissionDecision | null> {
5 try {
6 for await (const hookResult of executePermissionRequestHooks(
7 tool.name, toolUseID, input, context,
8 permissionMode, suggestions, context.abortController.signal,
9 )) {
10 if (!hookResult.permissionRequestResult) continue
11 const decision = hookResult.permissionRequestResult
12 if (decision.behavior === 'allow') {
13 // Persist updates, return allow
14 return { behavior: 'allow', updatedInput: finalInput, decisionReason: { type: 'hook', ... } }
15 }
16 if (decision.behavior === 'deny') {
17 return { behavior: 'deny', message: ..., decisionReason: { type: 'hook', ... } }
18 }
19 }
20 } catch (error) {
21 logError(new Error('PermissionRequest hook failed for headless agent', { cause: toError(error) }))
22 }
23 return null // Caller will execute auto-deny
24}

Permission Queue and React State Bridging

The permission dialog isn't a simple window.confirm — it's a full React component that supports editing input, selecting persistence options, providing feedback, and more. The permission system interfaces with React state through the PermissionQueueOps interface:

TypeScript
1// src/hooks/toolPermission/PermissionContext.ts, lines 357-379
2function createPermissionQueueOps(
3 setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>,
4): PermissionQueueOps {
5 return {
6 push(item: ToolUseConfirm) {
7 setToolUseConfirmQueue(queue => [...queue, item])
8 },
9 remove(toolUseID: string) {
10 setToolUseConfirmQueue(queue =>
11 queue.filter(item => item.toolUseID !== toolUseID),
12 )
13 },
14 update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
15 setToolUseConfirmQueue(queue =>
16 queue.map(item =>
17 item.toolUseID === toolUseID ? { ...item, ...patch } : item,
18 ),
19 )
20 },
21 }
22}

This is an elegant "bridging" design — the permission logic has zero dependency on React. PermissionQueueOps is a generic interface, and any system that can provide push/remove/update operations can replace the React implementation. This also means the permission system can be ported to other UI frameworks or entirely UI-less environments.

recheckPermission: Hot-reloading Permissions

The permission dialog also has a special recheckPermission callback that allows permissions to be re-evaluated while the dialog is displayed:

TypeScript
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 204-231
2async recheckPermission() {
3 if (isResolved()) return
4 const freshResult = await hasPermissionsToUseTool(
5 ctx.tool, ctx.input, ctx.toolUseContext, ctx.assistantMessage, ctx.toolUseID,
6 )
7 if (freshResult.behavior === 'allow') {
8 if (!claim()) return
9 if (bridgeCallbacks && bridgeRequestId) {
10 bridgeCallbacks.cancelRequest(bridgeRequestId)
11 }
12 channelUnsubscribe?.()
13 ctx.removeFromQueue()
14 ctx.logDecision({ decision: 'accept', source: 'config' })
15 resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
16 }
17},

This solves a practical scenario: a user switches permission modes on claude.ai (Bridge) — say from default to bypassPermissions — and the permission dialog currently displayed on the CLI side should immediately disappear, with the operation continuing automatically. recheckPermission is called when a mode switch event fires, re-evaluates permissions, and if the new mode allows the operation, auto-approves it and dismisses the dialog.


Visual Feedback for Classifier Auto-Approval

When the classifier auto-approves an operation before the user makes a decision, the interactive handler displays a brief checkmark:

TypeScript
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 469-521
2onAllow: decisionReason => {
3 if (!claim()) return
4 // ...
5
6 // Show auto-approval transition animation
7 if (feature('TRANSCRIPT_CLASSIFIER')) {
8 ctx.updateQueueItem({
9 classifierCheckInProgress: false,
10 classifierAutoApproved: true,
11 classifierMatchedRule: matchedRule,
12 })
13 }
14
15 // Keep the checkmark visible for a period, then remove the dialog
16 // 3 seconds when terminal is focused, 1 second when not
17 // User can press Esc to dismiss early (via onDismissCheckmark)
18 const checkmarkMs = getTerminalFocused() ? 3000 : 1000
19 checkmarkTransitionTimer = setTimeout(() => {
20 ctx.removeFromQueue()
21 }, checkmarkMs)
22},

This design considers user perception:

  • Terminal focused: The user is likely looking at the screen, so give them 3 seconds to notice the operation was auto-approved
  • Terminal unfocused: The user isn't watching, so 1 second is enough
  • Manually dismissible: Pressing Esc closes it immediately without blocking the workflow
  • Abort-safe: If a sibling abort occurs during the checkmark display (e.g., another tool fails), the checkmark dialog is properly cleaned up

Transferable Patterns: Building Layered Permission Systems for AI Applications

The permission system design in Claude Code can be distilled into a set of general-purpose architectural patterns for AI application permissions. Here are the key design principles and their corresponding implementation strategies.

Principle 1: Layered Evaluation, Deny First

流程
Deny rules Safety checks Mode check Allow rules Tool's own judgment Default Ask

Each layer does one thing, and the evaluation order is fixed. Deny rules come first to ensure the security baseline cannot be bypassed. This pattern can be directly applied to any AI application requiring permission control.

Principle 2: Immutable State + Atomic Decisions

Permission state is protected with DeepImmutable, and the decision process is guaranteed atomic with ResolveOnce. When your system has multiple asynchronous sources that might simultaneously make decisions (users, automated systems, remote approvers), the claim() pattern is a lightweight yet reliable solution.

Principle 3: Secure Defaults + Explicit Relaxation

TypeScript
1// Default state: most restrictive
2const empty = {
3 mode: 'default',
4 alwaysAllowRules: {},
5 alwaysDenyRules: {},
6 alwaysAskRules: {},
7 isBypassPermissionsModeAvailable: false,
8}

The system starts in its "safest" state. Every relaxation requires an explicit action — a user clicking "Always allow," an admin configuring a policy, or a command-line argument being passed. This ensures security is never accidentally weakened due to missing configuration.

Principle 4: Rule Source Tracking

Every rule carries source information (which configuration file, which layer). This is used not only for priority ordering but also for auditing — when a permission issue arises, you can pinpoint exactly "where this rule came from."

TypeScript
1type PermissionRule = {
2 source: PermissionRuleSource // 'projectSettings' | 'userSettings' | ...
3 ruleBehavior: 'allow' | 'deny' | 'ask'
4 ruleValue: PermissionRuleValue // { toolName: string, ruleContent?: string }
5}

Principle 5: Handler Separation

Different runtime environments have different permission requirements. Claude Code's three handler patterns — interactive (competitive), coordinator (serial pre-screening), and swarm (mailbox forwarding) — demonstrate how to adapt a single rule system to different execution environments.

The core abstraction is PermissionContext: it encapsulates all permission operations (logging, persistence, queue management), letting handlers focus solely on flow control. When adding a new runtime environment, you only need to implement a new handler function — no modifications to the rule evaluation logic are required.

Principle 6: Progressive Trust

The auto mode classifier doesn't call AI evaluation on every operation — it first uses fast paths to filter out clearly safe and clearly dangerous operations:

流程
Safety checks (non-bypassable) acceptEdits fast path Allowlist fast path Classifier API

Each additional fast-path layer eliminates a batch of unnecessary API calls. This pattern applies to any system that uses AI for runtime decision-making.

Practical Architecture Recommendations

If you're building a permission system for an AI application, you can start with this minimal architecture:

TypeScript
1// Minimal permission system skeleton
2type PermissionDecision = { behavior: 'allow' | 'deny' | 'ask' }
3
4type PermissionRule = {
5 source: string
6 behavior: 'allow' | 'deny' | 'ask'
7 pattern: string // Matches tool name or operation content
8}
9
10// 1. Static evaluation
11function evaluateStaticRules(
12 action: string,
13 rules: PermissionRule[],
14): PermissionDecision | null {
15 // Deny first
16 const denyMatch = rules.find(r => r.behavior === 'deny' && matches(action, r.pattern))
17 if (denyMatch) return { behavior: 'deny' }
18
19 // Allow match
20 const allowMatch = rules.find(r => r.behavior === 'allow' && matches(action, r.pattern))
21 if (allowMatch) return { behavior: 'allow' }
22
23 return null // No rule matched, pass to dynamic evaluation
24}
25
26// 2. Dynamic evaluation (extensible)
27async function evaluateDynamic(
28 action: string,
29 handlers: PermissionHandler[],
30): Promise<PermissionDecision> {
31 for (const handler of handlers) {
32 const decision = await handler.evaluate(action)
33 if (decision) return decision
34 }
35 return { behavior: 'ask' } // Fallback: ask the user
36}

Then incrementally add features as needed:

  • Immutable state protection: Prevent runtime modifications
  • Atomic racing: When there are multiple asynchronous decision sources
  • Source tracking: When auditing or debugging is needed
  • Classifier integration: When the action space is too large for rules to cover
  • Handler separation: When there are multiple runtime environments

Conclusion

Claude Code's permission system is a multi-layered defense system. Its core insight is that permissions are not a binary choice (allow/deny), but a continuous spectrum across multiple dimensions.

  • Mode dimension: From bypassPermissions to dontAsk, users choose their own risk tolerance
  • Rule dimension: From blanket tool-level to content-level, supporting fine-grained permission control
  • Source dimension: From policies to sessions, multiple configuration layers stack on top of each other
  • Role dimension: The main agent, coordinator, and swarm workers each have distinct handling flows
  • Temporal dimension: The classifier, Hooks, and user interaction race along the timeline, with the first to decide winning

The engineering implementation of this system contains many patterns worth learning: DeepImmutable protects state safety, ResolveOnce guarantees atomicity, handler separation ensures extensibility, and progressive fast paths reduce unnecessary computation. These patterns are not limited to permission systems — any system involving multi-source asynchronous decision-making can draw from them.

Finally, returning to the opening question: where should the permission boundary be drawn? Claude Code's answer is — don't draw a fixed line; instead, provide a toolkit that lets each user draw their own. This is perhaps the most pragmatic approach to AI tool safety today.