Skip to content

Permission Pipeline

Claude doesn’t just ask “allow or deny?” — it classifies every action through 6 layers of security before deciding. Most calls never reach you. The layers catch them first.


Why Not Binary Allow/Deny?

A single yes/no dialog fails in two ways:

Permission fatigue: A complex task might involve 50 tool calls. If every call requires approval, you stop reading the prompts by #20 and start clicking “allow” reflexively. That defeats the purpose of having security.

Missing context: rm temp.log and rm -rf / are both “rm” commands. A binary classifier that matches on tool name cannot tell the difference. It either blocks both (too restrictive) or allows both (dangerous).

The 6-layer pipeline solves both problems. Routine safe calls are fast-pathed at Layer 1 without prompting you. Dangerous patterns are blocked at deep layers regardless of your allow rules.


The 6-Layer Pipeline

flowchart TD IN([Tool call arrives]) L1["Layer 1: Safe Tool Allowlist\nFileRead, Grep, Glob, TaskGet, AskUser\nTeam tools → always pass\n~99% of calls fast-pathed here"] L2["Layer 2: Permission Mode\ndefault / plan / acceptEdits /\nauto / dontAsk / bypassPermissions"] L3["Layer 3: Shell Rule Matching\nexact / prefix / wildcard patterns\nfrom your settings"] L4["Layer 4: Dangerous Patterns\ninterpreters, eval, exec, sudo,\nnpx, bunx always blocked"] L5["Layer 5: Command Security (AST)\nBash AST analysis: substitutions,\nZsh expansions, heredoc injection"] L6["Layer 6: Denial Tracking\n3 consecutive denials OR 20 total\n→ prompt user directly"] PASS([Execute tool]) DENY([Block or prompt user]) IN --> L1 L1 -- "safe" --> PASS L1 -- "unknown" --> L2 L2 -- "denied by mode" --> DENY L2 -- "check rules" --> L3 L3 -- "rule allows" --> PASS L3 -- "no match" --> L4 L4 -- blocked pattern --> DENY L4 -- clean --> L5 L5 -- injection detected --> DENY L5 -- clean --> L6 L6 -- circuit breaker tripped --> DENY L6 -- clear --> PASS style IN fill:#1e293b,color:#94a3b8,stroke:#334155 style PASS fill:#14532d,color:#86efac,stroke:#166534 style DENY fill:#7c2d12,color:#fda4af,stroke:#9a3412 style L1 fill:#1e293b,color:#7dd3fc,stroke:#334155 style L2 fill:#1e293b,color:#7dd3fc,stroke:#334155 style L3 fill:#1e293b,color:#fcd34d,stroke:#334155 style L4 fill:#1e293b,color:#fda4af,stroke:#334155 style L5 fill:#1e293b,color:#fda4af,stroke:#334155 style L6 fill:#1e293b,color:#c4b5fd,stroke:#334155

Layer 1: Safe Tool Allowlist

Certain tools are unconditionally safe. They never mutate system state, so they never need permission checks.

Always fast-pathed: FileRead, Grep, Glob, TaskGet, TaskList, AskUser, LSP queries, WebSearch, WebFetch.

Each teammate in an agent team also has its own permission pipeline — permission requests from subagents bubble up to the parent rather than short-circuiting at Layer 1.

Deep Dive: Safe Tool Allowlist (Complete)

The full safe tool allowlist — these tools NEVER trigger a permission check:

CategoryTools
File readingFileRead, Grep, Glob
Semantic queriesLSP (all language server queries)
Task managementTaskCreate, TaskGet, TaskList, TaskUpdate, TaskStop, TaskOutput
User interactionAskUserQuestion
PlanningEnterPlanMode, ExitPlanMode
Team coordinationTeamCreate, TeamDelete, SendMessage
SystemSleep
Network (read-only)WebSearch, WebFetch

Team tools are safe because each teammate runs its own permission pipeline — permissions from subagents bubble up to the parent agent rather than being auto-approved locally.


Layer 2: Permission Mode

Six modes control the baseline behavior for everything that clears Layer 1:

ModeBehaviorUse Case
defaultAsk for every non-safe tool callMaximum oversight
planRead-only — all write/execute operations deniedSafe exploration, planning
acceptEditsAuto-approve file edits; still asks for bashTrust code edits, verify commands
autoBackground classifier decides; asks only for uncertain casesAutonomous tasks
dontAskAuto-approve non-destructive operationsTrusted workflows
bypassPermissionsAllow everything — no promptsCI/CD, scripted pipelines

plan mode enforces read-only at Layer 2 itself — it never reaches the shell rules or bash analysis layers because writes are denied before they get there.

There is also an internal bubble mode used by subagents: permission requests float up to the parent agent’s pipeline rather than being resolved locally.


Layer 3: Shell Rule Matching

This layer checks your configured allow rules from ~/.claude/settings.json or .claude/settings.json. Three pattern types:

Pattern TypeSyntaxMatches
Exactnpm testOnly npm test, nothing else
Prefixnpm:*Any npm command
Wildcardgit commit -m *Git commits with any message

Escaping: Use \* to match a literal asterisk in a command.

Rules are checked in order. First match wins. If no rule matches, the call continues down to Layer 4.


Layer 4: Dangerous Patterns

Certain patterns are always blocked, regardless of any allow rule you configure.

Blocked interpreters: python, python3, node, deno, ruby, perl, php, and similar.

Blocked operators: eval, exec, sudo, ssh, npx, bunx, npm run, yarn run.

Why? The interpreter backdoor problem.

If you write an allow rule python:*, you intend to permit running Python scripts. But that rule also permits:

python -c 'import os; os.system("rm -rf /")'

An allow rule for an interpreter is effectively a wildcard for every possible command that interpreter can execute. The system refuses to honor such rules because the risk cannot be bounded.

Even bypassPermissions mode does not override Layer 4 blocks on the most dangerous patterns.

Deep Dive: Dangerous Patterns (Complete List)

14 Command Substitution Patterns Detected:

PatternExampleRisk
$(...)$(rm -rf /)Standard substitution
`...``malicious`Legacy backtick form
${VAR:-$(cmd)}${X:-$(curl evil)}Parameter expansion with embedded sub
<(...)<(nc evil 443)Process substitution (input)
>(...)>(tee /etc/passwd)Process substitution (output)
$((...))$(($(cmd)))Arithmetic with embedded sub
${!ref}${!var}Indirect expansion
=(...) (Zsh)=curl evil.comEquals expansion → full path execution
$[...]$[$(cmd)]Legacy arithmetic
~[...] (Zsh)~[malicious]Named directory expansion
(e:...) (Zsh)(e:'cmd')Glob qualifier eval
<#...> (PowerShell)<# $(cmd) #>Block comment injection
Nested heredoc $()<<EOF\n$(cmd)\nEOFCommand sub inside heredoc
Array expansion${arr[$(cmd)]}Index with substitution

23 Dangerous Zsh Commands:

CommandRisk Category
zmodloadGateway to module attacks (load network, filesystem modules)
zmodload zsh/net/tcpEnable raw TCP connections
ztcpDirect TCP socket connections (data exfiltration)
zftpFTP operations (data exfiltration)
zf_rm, zf_mv, zf_ln, zf_mkdir, zf_rmdirBuiltin file operations (bypass binary checks)
sysopen, syswrite, sysread, sysseekLow-level I/O (bypass file monitoring)
zptyPseudo-terminal (spawn hidden processes)
zselectI/O multiplexing (coordinate exfiltration)
zcompileCompile scripts (persistence)
zparseoptsParse options (utility for complex attacks)
zstyleStyle system (can trigger callbacks)
autoloadLazy-load functions (persistence mechanism)
bindkeyRebind keys (input hijacking)
accept-line-and-down-historyHistory manipulation
fcFix command (edit and re-execute history)

Layer 5: Command Security (Bash AST)

For commands that clear Layers 1–4, the system performs structural analysis of the command. This is not string matching — it parses the command into an abstract syntax tree and inspects its structure.

Command substitution patterns detected (14 total):

$(malicious) # standard substitution
${VAR:-$(cmd)} # parameter expansion with substitution
<(process-substitution) # process substitution
`backtick-substitution` # legacy form

Zsh-specific expansions detected (23 total):

=curl evil.com # Zsh equals expansion: expands to /usr/bin/curl evil.com
zmodload zsh/net/tcp # load network module
ztcp # direct TCP connection
zf_rm # zsh file removal

The Zsh equals expansion is the subtlest: =curl evil.com expands to the full path of curl (/usr/bin/curl evil.com) and executes it. A deny rule on "curl" would not match this string — Layer 5 catches it by recognizing the =command expansion pattern.

Heredoc injection:

Terminal window
cat <<EOF
$(dangerous-command)
EOF

Command substitution inside a heredoc is detected and blocked.


Layer 6: Denial Tracking

The final layer is a circuit breaker. If Claude keeps attempting the same blocked action, the system stops trying to classify it and prompts you directly.

FUNCTION trackDenial(toolCall):
state.consecutiveDenials++
state.totalDenials++
IF state.consecutiveDenials >= 3 OR state.totalDenials >= 20:
promptUserDirectly(toolCall) // circuit breaker
state.consecutiveDenials = 0 // reset streak
FUNCTION recordSuccess(toolCall):
state.consecutiveDenials = 0 // reset streak
// totalDenials keeps counting — circuit breaker tracks overall session

The consecutive count resets on any successful execution, but the total count persists through the session. This prevents an agent from slowly draining the total limit across many different tool types.


Writing Effective Allow Rules

# Safest: exact match
npm test
# Convenient: prefix wildcard
git:*
# Flexible: command + wildcard argument
docker build -t *
# NEVER DO THIS — interpreter backdoor
python:*
node:*
deno:*

The rule of thumb: the more specific the rule, the safer. Exact rules protect you from prompt injection attacks that try to slip unexpected commands through a broad prefix rule.

Deep Dive: The Permission Orchestrator (canUseTool)

When a tool needs permission, the system routes through one of three handler paths depending on the agent’s role:

FUNCTION canUseTool(tool, input, context):
// Run through layers 1-6
result = hasPermissionsToUseTool(tool, input, context)
IF result == "allow": RETURN allow // Layers approved
IF result == "deny": RETURN deny // Layers blocked
// result == "ask" → Route to appropriate handler
IF context.isCoordinator:
// Coordinator decides autonomously (restricted tool set)
RETURN coordinatorHandler(tool, input)
ELSE IF context.isSwarmWorker:
// Bubble permission up to parent agent
RETURN swarmWorkerHandler(tool, input, context.parentAgent)
ELSE:
// Show terminal UI dialog to user
RETURN interactiveHandler(tool, input, context.terminal)

Speculative classification: BashTool starts the classifier check in parallel with input parsing:

// BashTool.execute():
classifierPromise = startClassifier(input) // Fire immediately
parsedInput = await parseInput(input) // Parse in parallel
classification = await classifierPromise // Await result (may already be done)

If parsing takes longer than classification (common for complex commands), the permission check is already complete by the time canUseTool() is called — zero additional latency.


Speculative Permission Check

A performance optimization: BashTool starts the classifier check in parallel with input parsing. By the time canUseTool() is called in the main flow, the background check may already have a result — reducing the latency cost of classification on the critical path.

FUNCTION handleBashToolCall(input):
classifierPromise = startClassifier(input) // background, immediately
parsedInput = parseInput(input) // foreground
classifierResult = await classifierPromise // likely already done
IF classifierResult == "allow":
execute(parsedInput)

Why This Matters to You

  • Stop Claude from asking permission repeatedly: Add a specific allow rule in your settings. Exact rules (npm test) stop the prompt for that exact command. Prefix rules (git:*) stop prompts for a whole family of commands.
  • Why some commands always ask even with broad rules: The dangerous patterns in Layer 4 override allow rules. If your rule matches a blocked pattern category, Layer 4 still blocks it.
  • Why Plan mode is completely read-only: Permission mode check at Layer 2 denies all write and execute operations before they reach your shell rules. No rule you write can override a plan-mode denial.
  • Why the system sometimes asks you directly: The Layer 6 circuit breaker tripped. After repeated denials, the system stops trying to auto-classify and escalates to you.
  • How to write effective rules: Exact > prefix > wildcard. Never allow interpreters. Specific rules are faster to evaluate and safer against injection.