Permissions

Agent system authors — Claude Code's permission system is 7 modes × 8 rule sources × 11 decision reasons × 10 hook events; unpacking each layer in the source.

What this chapter covers

The permission system answers “can this be done” — the trust decision layer of modes / rules / hooks / classifier.

Orthogonal to Execution Environment, which answers “where is this done” (worktree / CCR / Seatbelt / bwrap physical isolation). Separate chapters for the two.

Most agent products’ permission design sits at one of two extremes: fully open (early Devin, default Aider) or ask everything (user fatigue → approve-all).

Claude Code takes the third path: make permissions a multi-layer decision system, which in source is the cross-product of 7 modes × 8 rule sources × 11 decision reasons × 10 hook events. This chapter unpacks that system.


Permission Modes: 7, not 4

The complete permission mode set in source (types/permissions.ts):

export const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',
  'bypassPermissions',
  'default',
  'dontAsk',
  'plan',
] as const

export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'

5 external + 2 internal = 7 modes. Full semantics (from utils/permissions/PermissionMode.ts’s PERMISSION_MODE_CONFIG):

Permission Modes · A User-Visible Trust State Machine Four modes, switchable per-turn. Status bar always shows the current mode. user can switch any time · mid-conversation default everyday baseline Reads AUTO Writes ASK Destructive ASK Hooks ACTIVE USE WHEN Daily work. You're reviewing the agent's edits before they land. RISK LEVEL Low acceptEdits trust small edits Reads AUTO Writes AUTO Destructive ASK Hooks ACTIVE USE WHEN Well-scoped task. You trust agent's incremental changes. RISK LEVEL Medium plan think, don't touch Reads AUTO Writes BLOCKED Destructive BLOCKED Hooks ACTIVE USE WHEN Planning phase. Agent thinks + reads, no side effects allowed. RISK LEVEL None (no writes) bypassPermissions high autonomy · risky Reads AUTO Writes AUTO Destructive AUTO Hooks ACTIVE USE WHEN Long-horizon task with vetted scope and deny-list backup. RISK LEVEL High Orthogonal to modes: settings.json deny-list ALWAYS applies · hooks ALWAYS run Even bypassPermissions can't override a denied command or a blocking hook

ModeTitleSemanticsRisk
defaultDefaultDaily baseline — read auto, write ask, destructive askLow
planPlan ModeReads + TaskCreate only, all writes blockedNone
acceptEditsAccept editsRead/write auto, destructive askMedium
bypassPermissionsBypassAll auto — but deny list / hooks still applyHigh
dontAskDon’t AskBypass-like, semantically means “don’t ask me”High
autoAuto modeant-only, feature-flagged TRANSCRIPT_CLASSIFIER — LLM classifier judges each commandVariable (classifier-controlled)
bubbleInternal mode, not user-exposed

Easily-missed points:

  • dontAsk vs bypassPermissions aren’t identical — their color / UI are different; both are color: 'error' but two separate mode constants. “dontAsk” expresses intent “I’m turning off prompts”; “bypassPermissions” is more like “grant everything.” Runtime behavior is similar, semantic signal differs
  • auto mode enables the bash classifier: each bash command goes through a YOLO classifier (source types/permissions.ts lines 346-397 YoloClassifierResult). 2-stage (fast / thinking), full telemetry on input/output tokens, cache stats, request IDs — the real implementation of using an LLM to judge tool-call safety
  • Plan mode’s symbol is PAUSE_ICON — visually reinforces “I’m observing but not acting”
  • acceptEdits / bypassPermissions / dontAsk all show ⏵⏵ — fast-forward semantics, same symbol as video players

Takeaway for your own agent: permission mode isn’t just a config, it’s a UI state. Visual feedback on mode change (color, symbol, status bar) is essential — without it, users quickly forget their trust level.


8 rule sources

PermissionRuleSource (types/permissions.ts) defines a strict enum of where a rule came from:

export type PermissionRuleSource =
  | 'userSettings'      // ~/.claude/settings.json
  | 'projectSettings'   // <repo>/.claude/settings.json (git-tracked)
  | 'localSettings'     // <repo>/.claude/settings.local.json (not git-tracked)
  | 'flagSettings'      // CLI --allowedTools / --disallowedTools
  | 'policySettings'    // Enterprise policy (Managed settings)
  | 'cliArg'            // CLI args
  | 'command'           // Rules added via /permissions command
  | 'session'           // "Always allow" picks in this session

Meaning of layers:

  • userSettings / projectSettings / localSettings — aligned with the 3 memory tiers (see Memory System)
  • policySettings — enterprise / IT mandatory rules. Policy can override all other sources for compliance
  • flagSettings / cliArg — one-off rules passed via CLI
  • session — rules appended by user clicking “always allow X” in conversation; discarded when session ends
  • command — added interactively via /permissions

Each PermissionRule carries its own source, so conflicts can be traced — during audit, you can answer “where did this allow come from.”

Takeaway for your own agent: don’t just store the final rule value — store (value, source) tuples. Security incident / compliance audit will need the provenance.


11 decision reasons

A permission decision is allow / ask / deny, but why has a strict 11-case taxonomy in PermissionDecisionReason (types/permissions.ts lines 271-324):

export type PermissionDecisionReason =
  | { type: 'rule'; rule: PermissionRule }
  | { type: 'mode'; mode: PermissionMode }
  | { type: 'subcommandResults'; reasons: Map<string, PermissionResult> }
  | { type: 'permissionPromptTool'; permissionPromptToolName: string; toolResult: unknown }
  | { type: 'hook'; hookName: string; hookSource?: string; reason?: string }
  | { type: 'asyncAgent'; reason: string }
  | { type: 'sandboxOverride'; reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }
  | { type: 'classifier'; classifier: string; reason: string }
  | { type: 'workingDir'; reason: string }
  | { type: 'safetyCheck'; reason: string; classifierApprovable: boolean }
  | { type: 'other'; reason: string }

Each type expresses a different decision source:

TypeMeaning
ruleMatched an explicit rule (allow / ask / deny)
modeCurrent permission mode’s default behavior
subcommandResultsCompound command (cmd1 && cmd2) — each subcommand judged separately, conjunction at end
permissionPromptToolAn external “permission prompt tool” returned the decision
hookPreToolUse hook returned permissionDecision
asyncAgentAsync agent classifier judgment
sandboxOverrideCommand marked “unsandboxable” or user --dangerously-disable-sandbox
classifierYOLO bash classifier judgment
workingDirPath not in allowed working dirs
safetyCheckStatic safety checks (sensitive files, Windows path bypasses, cross-machine bridge msgs, etc.)
otherCatch-all

Why so many types: audit + explainability. When a user asks “why was this denied”, the UI can expand the full decision path — not “blocked”, but “because policySettings rule X (deny) matched”.

Takeaway for your own agent: separate decision outcome (allow / deny) from decision reason (1 of 11). The former is binary; the latter lets you explain, audit, replay.


Hook System: 10 events

Source has 10 hook events, not the 5 I earlier wrote (from utils/hooks.ts hook event names):

EventFires when
PreToolUseBefore tool call
PostToolUseAfter tool success
PostToolUseFailureAfter tool failure (new)
UserPromptSubmitUser sends message
StopEnd of each turn
StopFailureTurn ended with exception
SubagentStopSubagent finished
PreCompactBefore compaction (see Compaction)
PostCompactAfter compaction
WorktreeCreateWhen a worktree is created

New events worth noting:

  • PostToolUseFailure: fires only on tool failure — lets you handle failures separately (Sentry, alerts) without mixing with success path
  • StopFailure / SubagentStop: separate Stop events for main agent vs subagent
  • WorktreeCreate: fires when a worktree is created — let you run setup scripts (pull deps, prep sandbox)

Hook output: full JSON schema

Source utils/hooks.ts lines 415-444 expose the hook’s full schema:

{
  continue: boolean,                // Continue? (false → break agent loop)
  suppressOutput: boolean,           // Suppress hook's stdout display
  stopReason: string,                // Reason (shown to user)
  decision: '"approve" | "block"',   // Simplified decision
  reason: string,                    // Decision reason
  systemMessage: string,             // System message injected to agent
  permissionDecision: '"allow" | "deny" | "ask"',
  hookSpecificOutput: {
    'PreToolUse':     { hookEventName, permissionDecision, permissionDecisionReason, updatedInput },
    'UserPromptSubmit': { hookEventName, additionalContext },
    'PostToolUse':    { hookEventName, additionalContext },
  }
}

Fields worth deep dive:

  1. updatedInput (PreToolUse only): hook can modify tool call arguments. Example: rewrite a Bash command to filter sensitive output before running, rewrite Write content to add a header, rewrite Read path to a sandboxed version. This is very powerful — hook isn’t just allow/deny, it’s interceptable and modifiable
  2. additionalContext (UserPromptSubmit / PostToolUse): hook can inject extra context into prompt / conversation. Example: “after user submits prompt, append current CI status”; “after tool success, append relevant docs”
  3. continue: false: hook can break the agent loop entirely, not just reject the current operation
  4. systemMessage: hook can inject system-level instructions to the agent — the strongest expression of hook capability, effectively extending the system prompt at runtime

Hook timeout: TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 100010 minutes. This length implies hooks are first-class citizens, not quick interceptors. They can run lint, tests, send emails, do complex analysis.

Non-JSON hook output

Hook output can be JSON or plain text (source parseHookOutput):

  • JSON: parsed by the schema above, takes the structured path
  • Plain text (doesn’t start with {): injected as additionalContext

This lowers the bar for writing hooks — simple hooks just echo a line to stdout.

Takeaway for your own agent: hook interfaces should support three tiers — plain text (simple) → JSON decision (medium) → updatedInput + systemMessage (advanced). Low bar, high ceiling; users upgrade as needed.


Process-level sandbox lives in the execution environment layer

This chapter does NOT cover process-level sandbox implementation (macOS Seatbelt / Linux bwrap+seccomp / Windows no sandbox) — that’s Execution Environment’s topic. This chapter only covers the permission decision layer.

How the two layers interact: the permission layer decides “whether to do it”; the sandbox layer decides “whether the action can touch sensitive resources”. bypassPermissions mode fully opens the permission layer, but the sandbox layer still applies — bwrap won’t let you write /etc/passwd just because you’re in bypassPermissions. This is the core of Four layers of defense discussed below.


Bash DANGEROUS_PATTERNS: auto mode’s allowlist protection

utils/permissions/dangerousPatterns.ts defines a dangerous bash prefix list used to automatically strip overly-permissive allow rules when entering auto mode:

export const CROSS_PLATFORM_CODE_EXEC = [
  // Interpreters
  'python', 'python3', 'python2', 'node', 'deno', 'tsx',
  'ruby', 'perl', 'php', 'lua',
  // Package runners
  'npx', 'bunx', 'npm run', 'yarn run', 'pnpm run', 'bun run',
  // Shells
  'bash', 'sh',
  // Remote
  'ssh',
] as const

export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
  ...CROSS_PLATFORM_CODE_EXEC,
  'zsh', 'fish', 'eval', 'exec', 'env', 'xargs', 'sudo',
  // Ant-only extensions
  ...(process.env.USER_TYPE === 'ant' ? [
    'fa run', 'coo',               // Ant internal tools
    'gh', 'gh api', 'curl', 'wget',  // Network / exfil
    'git',                          // git config core.sshCommand = arbitrary code
    'kubectl', 'aws', 'gcloud', 'gsutil',  // Cloud resource writes
  ] : []),
]

Design intent (source comment):

An allow rule like Bash(python:*) or PowerShell(node:*) lets the model run arbitrary code via that interpreter, bypassing the auto-mode classifier.

Translation: if the user writes Bash(python:*) as an allow rule, the model can execute arbitrary code via python — bypassing the bash classifier. This “surface restriction but actually permissive” rule is explicitly stripped on auto-mode entry.

The ant-only extension list is particularly interesting: git is on it — because git config core.sshCommand can run arbitrary code, a git allow prefix isn’t safe. This is a pattern list from an attacker’s perspective, not trusting command names at face value.

Takeaway for your own agent: allowlists can’t just match prefixes — consider what execution paths each prefix actually enables. python:* / node:* effectively mean “allow arbitrary code”; they should be denied or require per-command classifier review.


Path Pattern: 4 path syntaxes in permission rules

Claude Code’s permission rules support 4 path syntaxes (utils/sandbox/sandbox-adapter.ts lines 83-97):

PrefixMeaning
//pathAbsolute (converts to /path)
/pathRelative to the settings file’s directory (expands to $SETTINGS_DIR/path)
~/pathUser home (handled by sandbox-runtime)
./path or pathRelative path (handled by sandbox-runtime)

The /path semantic is Claude Code-specific — a single leading slash doesn’t mean absolute, it means “relative to the settings.json’s directory.” This lets one settings file work across machines as long as the directory structure is consistently relative.

Takeaway for your own agent: path syntax in settings should support “relative to the settings file itself” — absolute paths break across machines / containers / CI. A “relative to settings” convention solves 80% of config drift issues.


Four layers of defense: defense in depth

Claude Code’s security model has four layers active within this chapter, plus a fifth layer from Execution Environment:

Permission decision layer (this chapter):
  1. Mode            — current permission mode's default behavior
  2. Rules           — allow / deny / ask rules (merged by source priority)
  3. Classifier      — auto mode's bash LLM classifier decision
  4. Hooks           — PreToolUse hook can intercept / modify / inject context

Execution environment layer (separate chapter):
  5. Platform Sandbox — Seatbelt / bwrap process-level isolation

Any layer denying → operation blocked. Security is a conjunction model:

  • Mode is default but rule says allow → allow (rule overrides mode default)
  • Mode is bypassPermissions but rule says denydenied (deny always wins)
  • Mode allow, rule allow, but hook returns deny → denied
  • Everything allowed but bwrap blocked file write → denied (execution environment layer catches it)

bypassPermissions can’t bypass hooks, nor the process sandbox — key design choice. “Max permission” mode only relaxes the default behavior, not explicit rules, hook interception, or OS-level isolation.

Source evidence: in the permission merge logic, deny always beats allowfail-closed default.


--dangerously-skip-permissions: the last escape hatch

This is a deliberately ugly CLI flag that bypasses the entire permission system. Two layers of design intent:

  1. User must actively pass it — not default-on
  2. The name itself is a warning — not --auto-approve, but “dangerously”

Paired with source sandboxOverride decision reason’s 'dangerouslyDisableSandbox' — any decision exempted via this flag has its reason explicitly recorded.

Takeaway for your own agent: dangerous escape hatches should violate UX conventions — deliberately unfriendly. Make users pause an extra second every time.


Takeaways for building your own agent

  1. Permission mode is UI state, not a config. 7 modes each have symbol / color / UI — visual feedback on switch is required
  2. Enumerate rule sources: userSettings / projectSettings / policySettings / cliArg / session / command — compliance audit will need the provenance
  3. Decision reasons must be granular: 11 PermissionDecisionReason types let “why deny” be explainable. Binary decision + multi-valued reason is the right abstraction
  4. Hook API should be three-tiered: plain text (simple) → JSON decision (medium) → updatedInput + systemMessage (advanced). Low bar + high ceiling
  5. Hooks aren’t just yes/no — they can modify arguments, inject system messages, break the agent loop. A first-class extension surface
  6. 10-minute hook timeout: hooks can run lint / test / complex analysis — not quick interceptors
  7. Permission layer and execution-environment layer are orthogonal: bypassPermissions skips the former but not the latter. See Execution Environment
  8. Bash classifier using LLM to judge command safety is a direction — 2-stage (fast/thinking) + full telemetry, worth learning from
  9. DANGEROUS_PATTERNS is an attacker-view listpython:* allow prefix equals “allow arbitrary code”. Allowlist design must consider actual execution paths, not literal names
  10. Deny always beats allow, fail-closed default. bypassPermissions only bypasses default behavior, not deny list or hooks
  11. Dangerous entry points violate UX conventions--dangerously-skip-permissions is deliberately ugly to make users pause
  12. Path syntax should support “relative to settings file” — required for cross-machine / CI / container workflows

Further reading

  • Claude Code source: types/permissions.ts, utils/permissions/, utils/hooks.ts, utils/permissions/dangerousPatterns.ts
  • Execution Environment — physical isolation layer beyond permissions (worktree / CCR / bwrap)
  • System Prompt Assembly — tool schemas are themselves part of the prompt
  • Design Lessons — generalizes this chapter’s principles into the “hook vs prompt” discussion
Was this page helpful?