Claude Code Permission Engine: Seven Steps, Bypass-Immune Gates

4 min readAI Agents

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.

ai-agentsclaude-codepermissionssafetyruntime

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

Files covered in this post7 files
src/
├── Tool.ts
├── hooks/
│   ├── useCanUseTool.tsx
│   └── toolPermission/
│       └── handlers/
│           ├── autoHandler.ts
│           ├── interactiveHandler.ts
│           └── headlessHandler.ts
└── utils/
    ├── permissions/
    │   ├── permissions.ts
    │   └── permissionSetup.ts

src/utils/permissions/permissions.ts

Central allow / ask / deny decision runtime

Critical

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.

Module
Permission Engine

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. 1

    1a. Entire tool denied by rule

    getDenyRuleForTool

    If 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. 2

    1b. Entire tool has an ask rule

    getAskRuleForTool / SandboxManager

    If 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. 3

    1c. Tool-specific permission check

    tool.checkPermissions

    Call 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. 4

    1d–1g. Process tool-specific result

    requiresUserInteraction / decisionReason.type

    Deny → stop. requiresUserInteraction → ask (cannot bypass). Content-specific ask rule → ask (bypass-resistant). safetyCheck → ask (bypass-IMMUNE — fires even in bypassPermissions mode).

  5. 5

    2a. Mode: bypassPermissions

    appState.toolPermissionContext.mode

    If mode is bypassPermissions (or plan mode with isBypassPermissionsModeAvailable), return allow. This only runs if all 1a–1g checks passed without triggering.

  6. 6

    2b. Always-allow rule match

    toolAlwaysAllowedRule

    If alwaysAllowRules contains a matching rule, return allow with the rule as the decision reason.

  7. 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-rule at startup (read-only)
  • userSettings — rules from ~/.claude/settings.json
  • projectSettings — rules from .claude/settings.json in the project
  • localSettings — 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.

javascriptpermissions.ts (simplified hasPermissionsToUseToolInner)

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')?

medium

Safety 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.