Claude Code Permission Engine: Seven Steps, Bypass-Immune Gates
The seven-step decision pipeline, which checks survive bypassPermissions mode, how Auto mode uses an AI Classifier, and why dangerous rules are stripped and restored rather than deleted.
If Tool.ts explains what a tool is, the permission stack explains when a tool is allowed to become real.
Without permissions, the runtime would be: model asks → runtime executes. That is not what this codebase does. The permission system is one of the most architecturally significant layers in Claude Code — it is where model autonomy meets explicit human control.
File structure
src/
├── Tool.ts
├── hooks/
│ ├── useCanUseTool.tsx
│ └── toolPermission/
│ └── handlers/
│ ├── autoHandler.ts
│ ├── interactiveHandler.ts
│ └── headlessHandler.ts
└── utils/
├── permissions/
│ ├── permissions.ts
│ └── permissionSetup.tssrc/utils/permissions/permissions.ts
Central allow / ask / deny decision runtime
Combines rules, modes, classifier hooks, and tool-specific checks into the permission decisions that govern every tool call. Contains the full decision tree inside hasPermissionsToUseToolInner and the mode-layer conversions in hasPermissionsToUseTool.
Key Exports
- hasPermissionsToUseTool
- checkRuleBasedPermissions
- createPermissionRequestMessage
- deletePermissionRule
- applyPermissionRulesToPermissionContext
Why It Matters
- Permission decisions are a seven-step decision tree, not a single prompt.
- Safety checks are bypass-immune — they prompt even in bypassPermissions mode.
- Mode transformations (dontAsk, auto, bypass) happen at the outer layer, after rule and tool checks complete.
Related Files
- Permission context typesrc/Tool.ts
- CanUseTool hooksrc/hooks/useCanUseTool.tsx
- Auto mode handlersrc/hooks/toolPermission/handlers/autoHandler.ts
- Permission setupsrc/utils/permissions/permissionSetup.ts
The two contexts
Before reading the decision function, it helps to distinguish two related types:
ToolPermissionContext — the immutable snapshot of permission state for the session. Lives in Tool.ts:
export type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource
alwaysDenyRules: ToolPermissionRulesBySource
alwaysAskRules: ToolPermissionRulesBySource
isBypassPermissionsModeAvailable: boolean
isAutoModeAvailable?: boolean
strippedDangerousRules?: ToolPermissionRulesBySource
shouldAvoidPermissionPrompts?: boolean
awaitAutomatedChecksBeforeDialog?: boolean
prePlanMode?: PermissionMode
}>
ToolUseContext — the full execution context that includes ToolPermissionContext indirectly through getAppState(). It also carries the abort controller, file state cache, app state accessor, and all session options.
The separation is intentional. ToolPermissionContext contains only what the permission engine needs. It is a snapshot read from app state, not a live reference. This is what makes the permission check deterministic for a given call — it cannot observe mid-check state mutations.
The decision pipeline
Permission decision tree — hasPermissionsToUseToolInner
Seven numbered checks run in sequence. The first match wins. The outer function adds mode-level transformations after this returns.
- 1
1a. Entire tool denied by rule
getDenyRuleForToolIf alwaysDenyRules contains a rule matching this tool by name or type, return deny immediately. No further checks needed.
This is the highest-priority gate. A deny rule targeting Bash(*) blocks every bash call before the tool even runs its own checkPermissions.
- 2
1b. Entire tool has an ask rule
getAskRuleForTool / SandboxManagerIf alwaysAskRules contains a matching rule, return ask — unless this is a sandboxed Bash call with autoAllowBashIfSandboxed enabled, in which case fall through to tool-specific checks.
- 3
1c. Tool-specific permission check
tool.checkPermissionsCall tool.checkPermissions(parsedInput, context). This is where tools encode their own input-level policy — e.g. Bash checking subcommand patterns against content-specific allow/deny rules.
- 4
1d–1g. Process tool-specific result
requiresUserInteraction / decisionReason.typeDeny → stop. requiresUserInteraction → ask (cannot bypass). Content-specific ask rule → ask (bypass-resistant). safetyCheck → ask (bypass-IMMUNE — fires even in bypassPermissions mode).
- 5
2a. Mode: bypassPermissions
appState.toolPermissionContext.modeIf mode is bypassPermissions (or plan mode with isBypassPermissionsModeAvailable), return allow. This only runs if all 1a–1g checks passed without triggering.
- 6
2b. Always-allow rule match
toolAlwaysAllowedRuleIf alwaysAllowRules contains a matching rule, return allow with the rule as the decision reason.
- 7
3. Convert passthrough to ask
behavior: 'passthrough' → 'ask'If the tool's checkPermissions returned passthrough (meaning 'no tool-specific opinion'), that becomes ask — escalating to the user.
The outer layer: mode transformations
hasPermissionsToUseToolInner returns the rule-based result. The outer hasPermissionsToUseTool applies mode-level transformations on top:
dontAsk mode — any ask result becomes deny. The runtime will not interrupt the user with a prompt; it refuses instead.
auto mode — any ask result goes to the AI Classifier (autoHandler.ts) instead of the user approval dialog. The classifier receives the full conversation context and the tool call, then decides allow or deny. This is what makes Claude Code feel less interrupt-heavy in Auto mode: the approval loop runs in milliseconds against a model, not seconds against a human.
Headless agents (shouldAvoidPermissionPrompts: true) — non-classifier-approvable safety checks that return ask are converted to deny. Background agents cannot show UI, so prompts that require interactive approval become automatic denials.
Denial tracking — when Auto mode allows a call, the consecutive denial count resets. When the classifier denies repeatedly, the system tracks that state and eventually stops attempting classifier approval, falling back to user prompts.
Rule stripping and restoration on Auto mode
When the user enters Auto mode, permissionSetup.ts strips "dangerous" allow rules from the active permission context rather than keeping them active. The original rules are saved into strippedDangerousRules on ToolPermissionContext.
When the user exits Auto mode, the stripped rules are restored exactly. The effect: allow rules that were safe for interactive approval (where the user actively said yes) may not be safe for autonomous approval (where the model decides). Auto mode does not inherit aggressive allow rules from interactive mode.
This is a subtle but important safety property. A rule like Bash(rm -rf:*) that a user explicitly allowed for their interactive session does not automatically grant the AI Classifier the right to approve the same command autonomously.
The safety check invariant
Steps 1g and 1e encode the most important invariant in the permission system:
Safety checks are bypass-immune.
When tool.checkPermissions returns {behavior: 'ask', decisionReason: {type: 'safetyCheck'}}, the runtime returns that result immediately — even if bypassPermissions mode is active.
The paths that protect .git/, .claude/, .vscode/, and shell configuration files (~/.bashrc, ~/.zshrc, etc.) all return safetyCheck. They are not overridable by user mode or rules.
Similarly, requiresUserInteraction() tools (step 1e) cannot be silently approved — they are interactive by design.
📝Why 1f is bypass-resistant but 1g is bypass-immune
Step 1f: content-specific ask rules from tool.checkPermissions — e.g. Bash(npm publish:*) — are bypass-resistant. The comment says: "just as deny rules are respected at step 1d." These rules fire before the mode check, so bypassPermissions cannot clear them.
Step 1g: safety checks — .git/, config files — are bypass-immune at a different level. The hasPermissionsToUseToolInner code returns them before step 2a (the bypass check). They cannot be cleared by any mode, because they are not subject to the mode gate at all.
This distinction matters if you are building a similar system: some policies should be mode-overridable, others should not be.
Rules have sources
Rules in ToolPermissionContext are grouped by source (ToolPermissionRulesBySource):
policySettings— enterprise/managed policy (read-only, cannot be deleted)flagSettings— CLI flags like--allowedTools(read-only)command— rules injected via--permission-ruleat startup (read-only)userSettings— rules from~/.claude/settings.jsonprojectSettings— rules from.claude/settings.jsonin the projectlocalSettings— rules from.claude/settings.local.json
Rules from policySettings, flagSettings, and command cannot be deleted by the user at runtime. Rules from user/project/local settings can be.
That is the governance structure: enterprise and CLI override user, user overrides project, local settings can expand project settings.
The permission decision pipeline across runtimes
Seven steps in sequence. First match wins. JS version matches the actual source; Python and Go show the same pattern.
Matches the actual decision tree from learning-claude-code
async function hasPermissionsToUseToolInner(tool, input, context) {
if (context.abortController.signal.aborted) throw new AbortError()
let appState = context.getAppState()
// 1a. Entire tool denied by rule
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
if (denyRule) return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule } }
// 1b. Entire tool has ask rule (unless sandboxed bash)
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
if (askRule && !canSandboxAutoAllow(tool, input)) {
return { behavior: 'ask', decisionReason: { type: 'rule', rule: askRule } }
}
// 1c. Tool-specific check
const toolResult = await tool.checkPermissions(tool.inputSchema.parse(input), context)
// 1d. Tool denied
if (toolResult.behavior === 'deny') return toolResult
// 1e. Requires interactive user approval — cannot bypass
if (tool.requiresUserInteraction?.() && toolResult.behavior === 'ask') return toolResult
// 1f. Content-specific ask rule (bypass-resistant)
if (toolResult.behavior === 'ask' && toolResult.decisionReason?.type === 'rule' &&
toolResult.decisionReason.rule.ruleBehavior === 'ask') return toolResult
// 1g. Safety check (bypass-IMMUNE — .git/, .claude/, shell configs)
if (toolResult.behavior === 'ask' && toolResult.decisionReason?.type === 'safetyCheck') return toolResult
// 2a. Bypass mode — only reachable if 1a–1g all passed
appState = context.getAppState() // re-read — state may have changed
const bypass = appState.toolPermissionContext.mode === 'bypassPermissions' ||
(appState.toolPermissionContext.mode === 'plan' && appState.toolPermissionContext.isBypassPermissionsModeAvailable)
if (bypass) return { behavior: 'allow', decisionReason: { type: 'mode', mode: appState.toolPermissionContext.mode } }
// 2b. Always-allow rule
const allowRule = toolAlwaysAllowedRule(appState.toolPermissionContext, tool)
if (allowRule) return { behavior: 'allow', decisionReason: { type: 'rule', rule: allowRule } }
// 3. Passthrough → ask
return toolResult.behavior === 'passthrough'
? { ...toolResult, behavior: 'ask' }
: toolResult
}What this architecture means for agent products
The permission system encodes three design principles worth reusing:
1. Decisions are ordered, not merged. The seven steps run in sequence with a first-match-wins rule. This prevents ambiguous outcomes when multiple checks could each produce different results.
2. Some policies are bypass-immune by construction. Safety checks return their result before the mode gate runs. You cannot configure your way around them. For an agent product with broad file system access, this is the right call.
3. Mode transformations happen at the outer layer. The inner decision tree is pure rule evaluation. Mode effects (dontAsk → deny, auto → classifier) are applied on top of the result. This separation makes the rule engine testable in isolation.
In Claude Code's permission system, which of the following is true about safety checks (decisionReason.type === 'safetyCheck')?
mediumSafety checks are returned from tool.checkPermissions when the tool detects writes to protected paths like .git/, .claude/, or shell config files.
ASafety checks can be overridden by setting mode to bypassPermissions
Incorrect.Incorrect. Safety checks return at step 1g, before the bypass mode check at step 2a. bypassPermissions cannot clear them.BSafety checks can be approved automatically by the AI classifier in auto mode
Incorrect.Partially incorrect. Non-classifier-approvable safety checks are blocked from the classifier path. classifierApprovable ones (sensitive paths) can go to the classifier, but this is the exception, not the rule.CSafety checks always prompt, even in bypassPermissions mode — they are bypass-immune
Correct!Correct. Steps 1a–1g run before the bypass mode check at step 2a. A safetyCheck result at step 1g returns immediately, never reaching the bypass gate.DSafety checks can be turned off by adding the path to alwaysAllowRules
Incorrect.No. alwaysAllowRules only applies at step 2b. Safety checks return at step 1g, before any allow-rule check.