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):
| Mode | Title | Semantics | Risk |
|---|---|---|---|
default | Default | Daily baseline — read auto, write ask, destructive ask | Low |
plan | Plan Mode | Reads + TaskCreate only, all writes blocked | None |
acceptEdits | Accept edits | Read/write auto, destructive ask | Medium |
bypassPermissions | Bypass | All auto — but deny list / hooks still apply | High |
dontAsk | Don’t Ask | Bypass-like, semantically means “don’t ask me” | High |
auto | Auto mode | ant-only, feature-flagged TRANSCRIPT_CLASSIFIER — LLM classifier judges each command | Variable (classifier-controlled) |
bubble | — | Internal mode, not user-exposed | — |
Easily-missed points:
dontAskvsbypassPermissionsaren’t identical — their color / UI are different; both arecolor: '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 differsautomode enables the bash classifier: each bash command goes through a YOLO classifier (sourcetypes/permissions.tslines 346-397YoloClassifierResult). 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/dontAskall 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:
| Type | Meaning |
|---|---|
rule | Matched an explicit rule (allow / ask / deny) |
mode | Current permission mode’s default behavior |
subcommandResults | Compound command (cmd1 && cmd2) — each subcommand judged separately, conjunction at end |
permissionPromptTool | An external “permission prompt tool” returned the decision |
hook | PreToolUse hook returned permissionDecision |
asyncAgent | Async agent classifier judgment |
sandboxOverride | Command marked “unsandboxable” or user --dangerously-disable-sandbox |
classifier | YOLO bash classifier judgment |
workingDir | Path not in allowed working dirs |
safetyCheck | Static safety checks (sensitive files, Windows path bypasses, cross-machine bridge msgs, etc.) |
other | Catch-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):
| Event | Fires when |
|---|---|
PreToolUse | Before tool call |
PostToolUse | After tool success |
PostToolUseFailure | After tool failure (new) |
UserPromptSubmit | User sends message |
Stop | End of each turn |
StopFailure | Turn ended with exception |
SubagentStop | Subagent finished |
PreCompact | Before compaction (see Compaction) |
PostCompact | After compaction |
WorktreeCreate | When 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 pathStopFailure/SubagentStop: separate Stop events for main agent vs subagentWorktreeCreate: 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:
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 modifiableadditionalContext(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”continue: false: hook can break the agent loop entirely, not just reject the current operationsystemMessage: 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 * 1000 — 10 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 asadditionalContext
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:*)orPowerShell(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):
| Prefix | Meaning |
|---|---|
//path | Absolute (converts to /path) |
/path | Relative to the settings file’s directory (expands to $SETTINGS_DIR/path) |
~/path | User home (handled by sandbox-runtime) |
./path or path | Relative 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
defaultbut rule saysallow→ allow (rule overrides mode default) - Mode is
bypassPermissionsbut rule saysdeny→ denied (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 allow — fail-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:
- User must actively pass it — not default-on
- 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
- Permission mode is UI state, not a config. 7 modes each have symbol / color / UI — visual feedback on switch is required
- Enumerate rule sources: userSettings / projectSettings / policySettings / cliArg / session / command — compliance audit will need the provenance
- Decision reasons must be granular: 11
PermissionDecisionReasontypes let “why deny” be explainable. Binary decision + multi-valued reason is the right abstraction - Hook API should be three-tiered: plain text (simple) → JSON decision (medium) → updatedInput + systemMessage (advanced). Low bar + high ceiling
- Hooks aren’t just yes/no — they can modify arguments, inject system messages, break the agent loop. A first-class extension surface
- 10-minute hook timeout: hooks can run lint / test / complex analysis — not quick interceptors
- Permission layer and execution-environment layer are orthogonal:
bypassPermissionsskips the former but not the latter. See Execution Environment - Bash classifier using LLM to judge command safety is a direction — 2-stage (fast/thinking) + full telemetry, worth learning from
DANGEROUS_PATTERNSis an attacker-view list —python:*allow prefix equals “allow arbitrary code”. Allowlist design must consider actual execution paths, not literal names- Deny always beats allow, fail-closed default.
bypassPermissionsonly bypasses default behavior, not deny list or hooks - Dangerous entry points violate UX conventions —
--dangerously-skip-permissionsis deliberately ugly to make users pause - 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