Skip to content

Chapter 7: Permission System

Difficulty: Intermediate | Reading time: ~55 minutes


Table of Contents

  1. Introduction
  2. Permission Modes
  3. Permission Rule System
  4. Permission Check Flow
  5. Bash Safety: AST Parsing & Classifier
  6. File Permission Checks
  7. Permission UI Components
  8. Sandbox Integration
  9. Hands-on: Build a Permission System
  10. Key Takeaways & What's Next
  11. Hands-on Build: Permission Check Integration

1. Introduction

Claude Code executes real tools — shell commands, file writes, web fetches — on behalf of an AI model. Without a robust permission system, a single malicious or mistaken instruction could delete files, exfiltrate data, or corrupt a production system.

The permission system sits between tool invocation and tool execution. Every time Claude wants to run a tool, the runtime calls checkPermissions() on that tool, then feeds the result through hasPermissionsToUseTool(). The final decision is one of three behaviors:

BehaviorMeaning
allowExecute immediately, no user prompt
askPause and prompt the user for approval
denyRefuse outright, return an error to Claude

This chapter traces every layer of that decision — from the 7-mode global setting, through layered rules from 8 sources, down to AST-level bash analysis and sandboxed filesystem enforcement.

Source files covered:

FileRole
src/types/permissions.tsCanonical type definitions (modes, rules, decisions)
src/utils/permissions/PermissionMode.tsMode config, display names, type guards
src/utils/permissions/permissions.tsRule lookup helpers, hasPermissionsToUseTool
src/utils/permissions/permissionRuleParser.tsRule string parsing/serialisation
src/utils/permissions/shellRuleMatching.tsWildcard/prefix rule matching
src/utils/permissions/filesystem.tsFile path permission checks
src/utils/permissions/dangerousPatterns.tsDangerous command/interpreter lists
src/utils/permissions/getNextPermissionMode.tsMode-cycle logic (Shift+Tab)
src/hooks/toolPermission/PermissionContext.tscreateResolveOnce, PermissionContext
src/hooks/toolPermission/handlers/interactiveHandler.tsInteractive permission flow
src/tools/BashTool/bashPermissions.tsbashToolHasPermission — tree-sitter gate
src/utils/sandbox/sandbox-adapter.tsSandboxManager integration

2. Permission Modes

The permission mode is a session-level setting that determines the default stance when no explicit rule matches. Modes are defined in src/types/permissions.ts:

typescript
// src/types/permissions.ts:16-29
export const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',
  'bypassPermissions',
  'default',
  'dontAsk',
  'plan',
] as const

export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]

// Internal adds 'auto' (ANT-only) and 'bubble' (coordinator mode)
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionMode

Mode Reference

ModeSymbolColorBehavior
defaultneutralAsk for unknown tools, allow known allow-rules
acceptEdits⏵⏵greenAuto-approve file edits within working directory
planblueRead-only planning; all write operations require approval
bypassPermissions⏵⏵redSkip all permission checks (dangerous)
dontAsk⏵⏵redConvert all ask decisions to deny
auto⏵⏵yellowANT-only: use AI classifier instead of prompting
bubbleInternal: coordinator delegates to parent agent

The display metadata lives in src/utils/permissions/PermissionMode.ts:44-91. The PERMISSION_MODE_CONFIG map associates each mode with a title, shortTitle, symbol, and color.

Mode Cycling (Shift+Tab)

Users cycle through modes with Shift+Tab. The cycle logic is in src/utils/permissions/getNextPermissionMode.ts:

default → acceptEdits → plan → (bypassPermissions?) → (auto?) → default

The bypassPermissions and auto modes are only available when the corresponding feature flags or permission bits are enabled (isBypassPermissionsModeAvailable, isAutoModeAvailable).

dontAsk vs bypassPermissions

These are often confused:

  • bypassPermissions skips checkPermissions() entirely — the tool runs unconditionally.
  • dontAsk still runs checkPermissions(), but converts any ask result into a deny. It is more restrictive than default, not less.

3. Permission Rule System

Rules are the fine-grained mechanism for allowing or denying specific tool invocations. Each rule has three parts:

typescript
// src/types/permissions.ts:75-79
type PermissionRule = {
  source: PermissionRuleSource   // where the rule came from
  ruleBehavior: PermissionBehavior  // 'allow' | 'deny' | 'ask'
  ruleValue: PermissionRuleValue    // { toolName, ruleContent? }
}

Rule Sources (Priority Order)

Rules are collected from 8 sources. Earlier sources win in conflict resolution:

PrioritySourceDescription
1policySettingsEnterprise managed policy (read-only)
2flagSettingsFeature flag overrides
3userSettings~/.claude/settings.json
4projectSettings.claude/settings.json in project root
5localSettings.claude/settings.local.json
6cliArg--allowedTools / --disallowedTools CLI flags
7command/permissions add slash command
8sessionApproved during this session (in memory only)

The full source list is assembled in src/utils/permissions/permissions.ts:109-114:

typescript
const PERMISSION_RULE_SOURCES = [
  ...SETTING_SOURCES,   // policySettings, flagSettings, userSettings, projectSettings, localSettings
  'cliArg',
  'command',
  'session',
] as const satisfies readonly PermissionRuleSource[]

Rule Format

Rules are stored as strings and parsed by src/utils/permissions/permissionRuleParser.ts:

ToolName                → whole-tool rule
ToolName(content)       → content-scoped rule
Bash(npm install)       → exact bash command
Bash(git *)             → wildcard: any git subcommand
Read(~/*.ts)            → read any .ts in home dir
WebFetch(https://api.example.com/*)  → specific URL pattern

The parser handles escaped parentheses in content — Bash(python -c "print\(1\)") correctly extracts python -c "print(1)" as the rule content (permissionRuleParser.ts:55-79).

Rule Behaviors

ruleBehaviorEffect
allowBypass the prompt; run immediately
denyBlock; return error to Claude
askForce prompt even if mode would allow

The ask behavior overrides permissive modes — if you have Bash(rm *) as an ask rule, the user will always be prompted before any rm command, even in acceptEdits mode.

Rule Matching

src/utils/permissions/shellRuleMatching.ts implements three rule shapes:

ShapeExampleMatch Logic
exactgit statusExact string equality
prefixnpm:* (legacy)Command starts with prefix
wildcardgit *, npm run *Glob-style with * as wildcard

The wildcard engine (matchWildcardPattern, line 90) converts the pattern to a regex, handling \* as a literal asterisk and \\ as a literal backslash. A trailing * makes the space-and-args optional so git * matches bare git too.


4. Permission Check Flow

Overview

mermaid
flowchart TD
    A[Tool invocation] --> B[tool.checkPermissions]
    B --> C{result.behavior}
    C -->|allow| D[Execute tool]
    C -->|deny| E[Return error to Claude]
    C -->|ask| F[hasPermissionsToUseTool]

    F --> G{Permission mode}
    G -->|bypassPermissions| D
    G -->|dontAsk| E
    G -->|default/acceptEdits/plan| H[Interactive handler]

    H --> I[pushToQueue — show dialog]
    H --> J[runHooks async]
    H --> K[Bash classifier async]
    H --> L[Bridge/Channel relay]

    I & J & K & L --> M{resolve-once race}
    M -->|first wins| N{Decision}
    N -->|allow| D
    N -->|deny| E

Step 1: tool.checkPermissions()

Each tool implements its own checkPermissions(). Examples:

  • BashTool (src/tools/BashTool/BashTool.tsx:539): delegates to bashToolHasPermission
  • FileEditTool (src/tools/FileEditTool/FileEditTool.ts:125): calls checkWritePermissionForTool

The result is a PermissionResult — either allow, deny, or ask with a message and optional suggestions.

Step 2: hasPermissionsToUseTool()

Defined in src/utils/permissions/permissions.ts:473, this function wraps the inner check with mode-level transformations:

  1. If bypassPermissions → return allow unconditionally
  2. If dontAsk and result is ask → convert to deny
  3. If auto mode → try AI classifier before showing dialog
  4. Otherwise → delegate to interactive or headless handler

Step 3: Interactive Permission Handler

src/hooks/toolPermission/handlers/interactiveHandler.ts manages the interactive case. It runs four concurrent racers for a single permission request:

RacerSourcePriority
User dialogLocal terminal UIFirst interaction wins
PermissionRequest hooksExternal scriptsBackground async
Bash classifierAI-based safety classifierBackground async
Bridge/Channel relayclaude.ai web, Telegram, iMessageRemote async

The resolve-once guard ensures only one racer wins:

typescript
// src/hooks/toolPermission/PermissionContext.ts:75-93
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
  let claimed = false
  let delivered = false
  return {
    resolve(value: T) {
      if (delivered) return
      delivered = true
      claimed = true
      resolve(value)
    },
    claim() {         // atomic check-and-mark
      if (claimed) return false
      claimed = true
      return true
    },
  }
}

Every racer calls claim() before any await. If claim() returns false, another racer already won — the caller silently drops its result. This prevents double-resolution even across concurrent async callbacks.

Step 4: Permission Context Object

createPermissionContext (src/hooks/toolPermission/PermissionContext.ts:96) creates a context object capturing:

  • pushToQueue / removeFromQueue / updateQueueItem — React state bridge
  • runHooks — executes PermissionRequest hooks from settings.json
  • tryClassifier — awaits the bash classifier result
  • handleUserAllow / handleHookAllow — persist and log approvals
  • cancelAndAbort — abort signal + rejection message

This design keeps the permission logic free of direct React dependencies — the React state is injected via the PermissionQueueOps interface (src/hooks/toolPermission/PermissionContext.ts:57-61).


5. Bash Safety: AST Parsing & Classifier

Bash commands require special treatment because shell syntax allows arbitrary code execution through command substitution, expansion, and piping.

Tree-sitter AST Gate

bashToolHasPermission (src/tools/BashTool/bashPermissions.ts:1663) begins with an AST parse:

typescript
// src/tools/BashTool/bashPermissions.ts:1688-1695
let astRoot = injectionCheckDisabled
  ? null
  : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled
    ? null
    : await parseCommandRaw(input.command)

let astResult: ParseForSecurityResult = astRoot
  ? parseForSecurityFromAst(input.command, astRoot)
  : { kind: 'parse-unavailable' }

The AST result has three shapes:

KindMeaningAction
simpleClean parse, SimpleCommand[]Check semantics, apply rules
too-complexSubstitution/expansion detectedApply deny rules, then ask
parse-unavailableWASM not loadedFall back to legacy regex path

The too-complex result fires when tree-sitter detects command substitution ($(...) or backticks), process substitution, heredocs, or control-flow. These constructs cannot be statically analyzed, so the command always requires user approval unless an exact deny/allow rule matches.

Dangerous Pattern Lists

src/utils/permissions/dangerousPatterns.ts defines patterns that are stripped when entering auto mode:

typescript
// src/utils/permissions/dangerousPatterns.ts:44-50
export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
  ...CROSS_PLATFORM_CODE_EXEC,  // python, node, deno, ruby, perl, bash, ssh...
  'zsh', 'fish', 'eval', 'exec', 'env', 'xargs', 'sudo',
]

An allow rule like Bash(python:*) would let Claude run arbitrary Python — defeating the classifier. permissionSetup.ts strips these rules before transitioning into auto mode.

Semantic Safety Checks

Even for simple AST results, semantic-level checks run on the command list. These catch constructs that parse cleanly but are dangerous by name — e.g. eval "rm -rf /" has a clean AST but is blocked by the semantic check.


6. File Permission Checks

File tools (Read, Edit, Write) use a gitignore-style pattern engine from src/utils/permissions/filesystem.ts.

Protected Files and Directories

typescript
// src/utils/permissions/filesystem.ts:57-79
export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules', '.bashrc', '.bash_profile',
  '.zshrc', '.zprofile', '.profile', '.ripgreprc',
  '.mcp.json', '.claude.json',
]

export const DANGEROUS_DIRECTORIES = [
  '.git', '.vscode', '.idea', '.claude',
]

These are never auto-edited — they require explicit user approval regardless of mode.

Path Matching

matchingRuleForInput uses the ignore package (gitignore semantics) to match file paths against permission rules. The function accepts:

  • Absolute paths
  • ~/path patterns (expanded to home directory)
  • ./path (relative to project root)
  • Glob patterns with **

Path traversal is detected and blocked at src/utils/permissions/filesystem.ts via containsPathTraversal.

Case-Insensitive Security

macOS and Windows filesystems are case-insensitive. The check at line 90 normalizes paths to lowercase before comparison:

typescript
// src/utils/permissions/filesystem.ts:90-92
export function normalizeCaseForComparison(path: string): string {
  return path.toLowerCase()
}

This prevents bypassing a deny rule for .claude/settings.json by accessing .ClaUdE/Settings.JSON.

Write Permission for FileEditTool

checkWritePermissionForTool in src/tools/FileEditTool/FileEditTool.ts:125-131 calls checkWritePermissionForTool which:

  1. Checks if the file path is in a deny rule
  2. Checks if the file is a dangerous file/directory
  3. In acceptEdits mode: auto-approves files within the working directory
  4. Otherwise: returns ask with suggestions to allow the path

7. Permission UI Components

The src/components/permissions/ directory contains Ink-based React components for each tool's permission dialog.

Component Registry

ComponentToolNotes
BashPermissionRequestBashShows command, sub-command breakdown
FileEditPermissionRequestEditShows diff preview
FileWritePermissionRequestWriteShows new file content
FilesystemPermissionRequestReadShows file path
WebFetchPermissionRequestWebFetchShows URL
SedEditPermissionRequestSedEditShows diff
FallbackPermissionRequestAnyGeneric fallback
SandboxPermissionRequestSandboxSandbox override

PermissionDialog

src/components/permissions/PermissionDialog.tsx is the shell that wraps all tool-specific components. It:

  1. Selects the right component based on tool.name
  2. Renders PermissionExplanation showing why approval is needed
  3. Provides Allow / Deny / Always Allow buttons
  4. Sends onAllow / onReject callbacks back to handleInteractivePermission

Classifier Indicator

When the bash classifier is running in the background, the dialog shows a spinner (classifierCheckInProgress: true). When the classifier approves, a green checkmark briefly appears before the dialog auto-dismisses (3 seconds if terminal focused, 1 second if not) — see interactiveHandler.ts:509-518.

PermissionRuleExplanation

src/components/permissions/PermissionRuleExplanation.tsx explains why a rule triggered:

  • Rule source: "from user settings", "from project settings"
  • Rule content: the matching rule string
  • Behavior: what the rule does

This transparency helps users understand and debug their permission configuration.


8. Sandbox Integration

The sandbox is a deeper security layer that enforces restrictions at the OS level, independent of the permission rule system.

Architecture

Permission Rules (Claude Code layer)

SandboxManager (adapter layer)

@anthropic-ai/sandbox-runtime (OS layer)

src/utils/sandbox/sandbox-adapter.ts bridges Claude Code's settings format to the sandbox-runtime package's SandboxRuntimeConfig.

Path Pattern Resolution

Claude Code uses custom path prefixes that sandbox-runtime doesn't know about (sandbox-adapter.ts:99-119):

PatternResolution
//pathAbsolute from root: /path
/pathRelative to settings file directory
~/pathPassed through to sandbox-runtime
./pathPassed through to sandbox-runtime

Filesystem Restrictions

The sandbox enforces read and write restrictions at the OS level. Rules come from:

  1. permissions.alwaysAllow / permissions.alwaysDeny rules for Read/Edit/Write tools
  2. sandbox.filesystem.allowWrite / sandbox.filesystem.denyWrite in settings
json
// Example settings.json sandbox config
{
  "sandbox": {
    "filesystem": {
      "allowWrite": ["~/projects/**", "./dist/**"],
      "denyWrite": ["~/.ssh/**", "~/.aws/**"]
    },
    "network": {
      "allowedDomains": ["api.github.com", "*.npmjs.com"]
    }
  }
}

Network Restrictions

Network rules are extracted from WebFetch permission rules and merged with sandbox.network settings. The allowedDomains list is passed to the sandbox runtime which enforces it at the process level.

shouldAllowManagedSandboxDomainsOnly

Enterprise deployments can set policySettings.sandbox.network.allowManagedDomainsOnly: true to restrict Claude Code to only the domains listed in managed policy — preventing users from adding their own network allow rules.


9. Hands-on: Build a Permission System

Let's build a simplified but complete permission system that demonstrates the key patterns from Claude Code's implementation.

See the companion file: examples/07-permission-system/permission-check.ts

The example implements:

  1. Multi-source rule loading — rules from user, project, session, and CLI sources
  2. Three-behavior rulesallow, deny, ask
  3. Wildcard pattern matchinggit *, npm run *
  4. Resolve-once concurrent guard — prevents double-resolution
  5. Mode-level overridesbypassPermissions, dontAsk

Running the Example

bash
cd examples/07-permission-system
npx ts-node permission-check.ts

Key Patterns to Notice

Pattern 1: Rule priority by source

Rules from policySettings override userSettings which override projectSettings. The getRulesForBehavior function in the example iterates sources in priority order and returns the first match.

Pattern 2: Resolve-once guard

typescript
const guard = createResolveOnce(resolve)
// Hook callback
hook().then(decision => {
  if (!guard.claim()) return  // Another racer won
  guard.resolve(decision)
})
// User dialog callback
onUserApprove(decision => {
  if (!guard.claim()) return
  guard.resolve(decision)
})

The claim() call is atomic — it both checks and marks. This closes the race window between checking isResolved() and calling resolve().

Pattern 3: Mode transformation

typescript
if (mode === 'dontAsk' && result.behavior === 'ask') {
  return { behavior: 'deny', message: 'dontAsk mode' }
}
if (mode === 'bypassPermissions') {
  return { behavior: 'allow', updatedInput: input }
}

Modes are applied after the tool's own checkPermissions, not before. This means deny rules still fire even in bypassPermissions mode for some tool types.


10. Key Takeaways & What's Next

Key Takeaways

1. Three-layer defense

Claude Code's permission system has three distinct layers:

  • Mode: session-level stance (default, acceptEdits, bypassPermissions)
  • Rules: tool+content-specific allow/deny/ask from 8 sources
  • Sandbox: OS-level filesystem and network enforcement

2. Tool-specific logic

checkPermissions() is implemented per-tool. Bash uses AST analysis. FileEdit uses gitignore-style path matching. This lets each tool apply the right safety semantics for its domain.

3. Resolve-once concurrency

The interactive permission handler races four async sources (user, hooks, classifier, bridge). The createResolveOnce guard ensures exactly one wins, using an atomic claim() that closes the check-then-set race window.

4. Transparency by design

Every ask result carries a decisionReason explaining why the tool needs approval. The UI surfaces this in PermissionRuleExplanation, making the system auditable.

5. Enterprise lockdown

policySettings is the highest-priority source and is read-only. Combined with allowManagedPermissionRulesOnly and allowManagedSandboxDomainsOnly, enterprise admins can fully control what Claude Code can access.

What's Next

  • Chapter 8: MCP Integration — How Claude Code connects to Model Context Protocol servers, and how MCP tool permissions interact with the native permission system
  • Chapter 9: Agent Coordination — How the bubble permission mode works in multi-agent hierarchies, and how swarm workers handle headless permission requests

Hands-on Build: Permission Check Integration

This section marks another significant upgrade to the demo. We add utils/permissions.ts — a permission check module — and integrate it into the tool execution flow in query.ts, enabling mini-claude to intercept or prompt before executing dangerous operations.

Project Structure Update

demo/
├── utils/
│   ├── messages.ts      # Chapter 4
│   └── permissions.ts   # ← New: permission check implementation
├── query.ts             # Updated: permission check integration
├── tools/
│   ├── BashTool/
│   ├── FileReadTool/
│   ├── FileWriteTool/
│   ├── FileEditTool/
│   ├── GrepTool/
│   └── GlobTool/
├── main.ts
├── Tool.ts
├── context.ts
├── services/api/
└── types/

utils/permissions.ts Walkthrough

The permission check module implements the core decision flow of Claude Code's permission system:

Tool call → Iterate rules → First matching rule determines behavior
  ↓              ↓              ↓
  allow       deny          ask
  (execute)  (reject+feedback to AI)  (prompt user)

Default rule hierarchy:

  1. Read-only tools (Read, Grep, Glob) → always allow
  2. Dangerous command patterns (rm -rf, mkfs, dd if=, etc.) → always deny
  3. Write operations (Bash, Write, Edit) → ask

These three layers embody Claude Code's core security philosophy: read-only operations pass freely, dangerous commands are firmly rejected, write operations are left to the user's judgment.

Three Permission Modes

ModeBehavior
defaultStrict rule execution — read-only allow, dangerous deny, writes ask
autoRead-only operations auto-approve, writes still require confirmation (simplified acceptEdits)
bypassPermissionsSkip all checks (development/debugging only, never use in production)

The real Claude Code has 7 modes (see Section 2 of this chapter); the demo simplifies to 3 to focus on core logic.

Integration with query.ts

checkPermission is passed as an optional callback into query(), via a new field in QueryOptions:

typescript
export interface QueryOptions {
  // ...existing fields
  checkPermission?: (toolName: string, input: Record<string, unknown>) => Promise<PermissionResult>;
}

The integration point is before tool execution — after the Agentic Loop receives tool calls, it checks permissions first:

  • allow → execute the tool normally
  • deny → skip tool execution and return an error message to the AI (e.g., "Permission denied: rm -rf is blocked by safety rules"), which lets the AI adjust its strategy
  • ask → log and proceed (the current demo has no interactive UI; Chapter 8's REPL will display a confirmation dialog, implementing true interactive user confirmation)

If no checkPermission callback is provided, behavior is identical to before — all tools execute unconditionally. This ensures backward compatibility.

Running the Demo

bash
cd demo && bun run main.ts

Try these interactions to verify the permission system:

you> delete all files in the current directory
# AI attempts rm -rf → denied → AI receives error and adjusts strategy

you> read package.json
# Read is a read-only tool → allowed directly, no confirmation needed

you> create a test.txt file
# Write is a write operation → ask → permission check info logged

Mapping to Real Claude Code

Demo FileReal FileWhat's Simplified
utils/permissions.tssrc/utils/permissions/permissions.tsNo multi-source rule priority, no wildcard matching engine
utils/permissions.ts dangerous patternssrc/utils/permissions/dangerousPatterns.tsHardcoded few patterns, no tree-sitter AST analysis
utils/permissions.ts mode switchingsrc/utils/permissions/PermissionMode.ts3 modes vs 7 modes
query.ts (permission callback)src/hooks/toolPermission/No resolve-once racing, no classifier, no bridge

What's Next

Chapter 8 will implement an interactive terminal UI (React + Ink), including user input, message rendering, and permission confirmation dialogs. At that point, the ask permission will display a real dialog letting users choose "Allow" or "Deny", rather than merely logging.


Source references verified against anthhub-claude-code commit tree.