The Permission System: Walking the Tightrope Between Autonomy and Safety
A deep dive into Claude Code's multi-layered permission evaluation — from Hook pre-screening to automated classifiers to rule matching, understanding the engineering behind AI tool safety
The Problem
Would you let an AI run rm -rf / without asking? Probably not. What about git push? The answer is less clear-cut — some people are perfectly fine pushing to their own development branch, while others insist on confirmation before pushing to main. And what about cat package.json? If every file read required clicking "Allow," the user experience would be maddening.
Every operation carries different risk, so where should the permission boundary be drawn?
This isn't a new problem. Unix's rwx permission model, Android's runtime permissions, the browser's same-origin policy — every platform strikes a balance between capability and security. But AI coding tools face more complex challenges:
- Vast action space: Beyond just reading and writing files, there's command execution, network requests, and calls to external services
- Risk assessment requires semantic understanding:
rm -rf node_modulesandrm -rf /look syntactically similar, but carry wildly different levels of risk - Conflicting user expectations: Users want both "automation" and "safety," both "speed" and "ask me first"
- Multi-role collaboration: The main agent, coordinator workers, and swarm workers have entirely different permission requirements
Claude Code's permission system tackles this problem with an elegantly layered evaluation pipeline. This article provides a complete analysis of its design at the source code level.
Permission Modes Overview
Before diving into the evaluation pipeline, let's look at the permission modes that Claude Code defines. These modes determine the system's "default posture."
Mode Definitions
Permission modes are defined in src/types/permissions.ts:
Note that the auto mode is gated by feature('TRANSCRIPT_CLASSIFIER') — this is an internal-only feature flag. The bubble mode is entirely internal and never appears in user-configurable options.
Mode Behavior Matrix
The specific behavior of each mode is configured in src/utils/permissions/PermissionMode.ts:
Here's a summary of each mode's semantics:
| Mode | Semantics | Typical Use Case |
|---|---|---|
default | All non-read-only operations require user confirmation | Day-to-day interactive use |
plan | Only generates plans, does not execute modifications | Code review, architecture discussions |
acceptEdits | Automatically approves file edits, but Bash commands still require confirmation | Trusting the model's editing capabilities |
bypassPermissions | Skips almost all permission checks | CI/CD environments, fully trusted scenarios |
dontAsk | Converts all ask results to deny | Non-interactive environments |
auto | Uses an AI classifier to automatically assess risk | Internal power users |
This spectrum design is elegant: from "full trust" to "zero trust," users can position themselves wherever suits their scenario. But modes are only the first layer — they determine the "default posture," while the actual permission decisions must pass through multiple layers of evaluation.
The Multi-Layered Evaluation Pipeline
The core entry point for permission evaluation is the hasPermissionsToUseTool function, defined in src/utils/permissions/permissions.ts. The entire pipeline can be divided into two major phases: static rule evaluation (synchronous, fast) and dynamic interactive evaluation (asynchronous, potentially involving user interaction).
Phase 1: Static Rule Evaluation (hasPermissionsToUseToolInner)
The complete static evaluation flow, ordered by priority:
The ordering of this sequence is intentional:
Deny takes priority: Regardless of the current mode, deny rules are always checked first. This guarantees a security baseline — even in bypassPermissions mode, explicit deny rules still take effect.
Safety checks cannot be bypassed: Steps 1f and 1g ensure that certain safety checks cannot be circumvented even in bypassPermissions mode. Modifications to .git/, .claude/, and shell configuration files always require confirmation. This embodies the "trust but verify" philosophy — you trust the AI's capabilities, but some operations have consequences too severe to skip.
Mode check sits in the middle: The bypassPermissions mode check comes after deny rules and safety checks, but before allow rules. This means bypass mode "skips normal permissions," not "skips everything."
Passthrough as fallback: If a tool's own checkPermissions returns passthrough (meaning "I don't have an opinion on this permission decision"), the system converts it to ask, ensuring no operation is silently permitted.
Phase 2: Dynamic Interactive Evaluation (useCanUseTool and beyond)
When Phase 1 returns ask, control flow enters the useCanUseTool hook. This is where the real complexity lives:
When result.behavior === 'ask', the system enters a multi-contender mode — five sources compete simultaneously to be the first to make a decision: Hooks, the classifier, the user, Bridge (remote via claude.ai), and Channels (Telegram, etc.). We'll explore this in detail in the following sections.
Deep Dive into the Rule System
Three Types of Rules
Permission rules come in three types, each with independent source tracking:
ToolPermissionRulesBySource is essentially Record<PermissionRuleSource, string[]>. Each source maps to a set of rule strings. The sources are defined in src/utils/permissions/permissions.ts:
The seven sources, ordered from highest to lowest priority:
- policySettings: Enterprise policies (admin-configured, cannot be overridden)
- flagSettings: Feature flags
- projectSettings: Project-level configuration (
.claude/settings.json) - localSettings: Local configuration (
.claude/settings.local.json) - userSettings: User global configuration (
~/.claude/settings.json) - cliArg: Command-line arguments
- session: Temporary choices made by the user during the current session
Rule Matching Mechanism
The matching logic supports two levels of granularity:
Tool-level matching: The rule "Bash" matches all Bash tool invocations.
Content-level matching: The rule "Bash(prefix:npm install)" only matches Bash commands that start with npm install.
MCP server-level matching: The rule "mcp__server1" matches all tools under that server.
Content-level matching is implemented through getRuleByContentsForTool:
The elegance of this design lies in the fact that it's not a simple "allow all / deny all" approach. Instead, it lets users set different permission levels for different operations on the same tool. You can allow Bash(prefix:npm test) but deny Bash(prefix:npm publish), or allow Bash(prefix:git status) but require confirmation for Bash(prefix:git push).
Rule Source Tracking Example
At runtime, a rule's lifecycle might look like this:
The DeepImmutable Design of ToolPermissionContext
Take a closer look at the type definition of ToolPermissionContext:
DeepImmutable is a recursive type utility that marks all levels of an object as readonly. This is not an accidental design choice — it addresses one of the most dangerous classes of bugs in a permission system: runtime permission state being accidentally modified.
Imagine this scenario:
With DeepImmutable, any code attempting to modify the permission context will trigger a compile-time error. To change permission state, you must create a new object through setToolPermissionContext — this ensures that permission state changes are trackable and atomic.
The initialization also uses a clear empty-state factory function:
The default mode is default, all rules are empty, and bypass is unavailable. This is a "secure by default" design — the system starts in its most restrictive state and requires explicit relaxation.
Filesystem Scoping
The permission system doesn't just check "what tools you can use" — it also checks "which files you can operate on." additionalWorkingDirectories is a key component of this mechanism.
Working Directory Boundaries
By default, Claude Code's file operations are restricted to the current working directory (cwd). But in practice, projects can span multiple directories — multiple subprojects in a monorepo, shared library directories, and so on. additionalWorkingDirectories allows users to extend this boundary.
Dangerous File and Directory Protection
Even within the working directory, certain files receive additional protection:
These files share a common characteristic: modifying them can lead to code execution or data exfiltration. A modified .bashrc means malicious code runs the next time a terminal is opened; a modified .gitconfig could lead to credential leakage; a modified .mcp.json could introduce a malicious MCP server.
Even in bypassPermissions or auto mode, modifications to these paths must go through user confirmation (the safety check in step 1g). This is the only non-configurable hard constraint in the entire permission system.
PermissionContext and the ResolveOnce Atomicity Pattern
When permission evaluation enters the interactive phase, the system faces a classic concurrency problem: multiple asynchronous sources may simultaneously make permission decisions. PermissionContext and ResolveOnce are the core mechanisms for solving this problem.
createPermissionContext
createPermissionContext is defined in src/hooks/toolPermission/PermissionContext.ts. It creates a context object that encapsulates all permission operations:
Note the last line, Object.freeze(ctx) — the context object is frozen and cannot be modified. This is consistent with the DeepImmutable design philosophy: permission-related objects should be immutable.
ResolveOnce: Atomic Decision Guarantee
When multiple sources compete to make a permission decision, the most dangerous scenario is a "double decision" — a Hook approves the operation while the user simultaneously clicks "Deny." If both decisions are executed, the system state becomes inconsistent.
ResolveOnce solves this problem with a clean atomicity pattern:
There are two flags here, claimed and delivered, and the distinction between them matters:
claimed: Indicates "someone has claimed the decision authority." Once set, subsequentclaim()calls from other contenders returnfalse.delivered: Indicates "the Promise has been resolved." Preventsresolvefrom being called multiple times.
Why two flags instead of one? Because in asynchronous callbacks, there may be await operations between claim() and resolve():
If only the delivered flag were used, two callbacks could both pass the !delivered check and then both execute await ctx.handleUserAllow, causing double processing. The atomic claim() check closes this window.
Three Permission Handlers
The interactive phase of the permission system is managed by three specialized handlers, each corresponding to a different runtime scenario.
interactiveHandler: Interactive Handling for the Main Agent
This is the most complex handler because it needs to coordinate the most competing sources. It's defined in src/hooks/toolPermission/handlers/interactiveHandler.ts.
Note the return type is void, not Promise. This function doesn't wait for a decision to complete — it sets up all callbacks and returns immediately. The decision happens asynchronously via callbacks.
The competing sources include:
- User interaction (onAllow / onReject / onAbort)
- Async Hook execution (runHooks)
- Bash classifier (executeAsyncClassifierCheck)
- Bridge remote response (approval/denial from claude.ai)
- Channel response (approval/denial from Telegram/iMessage, etc.)
One noteworthy detail is the classifier's user interaction protection mechanism:
When the user starts interacting with the permission dialog (pressing arrow keys, Tab, or typing), the classifier's auto-approval is canceled. But there's a 200ms grace period — this prevents accidental keypresses when the dialog first appears from prematurely canceling the classifier. This kind of fine-tuned UX polish reflects the engineering team's experience.
coordinatorHandler: Serial Pre-screening for Coordinator Workers
The coordinator worker's (coordinator sub-agent) handling logic is simpler. Since it runs within the main agent's context but can't directly display UI, it first runs automated checks serially, and only falls back to an interactive dialog if none of them can make a decision:
The key difference: the interactive handler lets Hooks and the classifier race in parallel with the user; the coordinator handler runs them serially before displaying the dialog. This is because the coordinator worker's awaitAutomatedChecksBeforeDialog flag is true — its design philosophy is "let automated systems try to resolve it first; only bother the user if they can't."
swarmWorkerHandler: Mailbox Forwarding for Swarm Workers
Swarm workers (cluster work nodes) are the most unique role — they can't directly interact with users or display permission dialogs. Their strategy is: try the classifier for auto-approval first, and if that fails, forward the permission request to the leader:
There's an elegant race condition guard here: register the callback first, then send the request. If the order were reversed, the following scenario could occur:
- Worker sends permission request to the leader
- Leader responds instantly
- The response arrives before the callback is registered
- The response is dropped
By registering first and sending second, even if the leader's response arrives before sendPermissionRequestViaMailbox returns, the callback is already in place to handle it.
Handler Dispatch Logic
In useCanUseTool.tsx, the three handlers are dispatched in the following order:
This is a classic chain of responsibility pattern: each handler either returns a decision (non-null) or returns null to pass control to the next handler. The final handleInteractivePermission serves as the fallback — it can always handle the request (by displaying UI).
Auto Mode and the AI Classifier
auto mode is the most cutting-edge part of the permission system. Rather than simply allowing or denying all operations, it uses an AI classifier to assess the risk of each operation.
Classifier Evaluation Flow
When the mode is auto, hasPermissionsToUseTool goes through a series of fast-path checks before returning ask:
This design embodies the concept of "progressive trust":
- Hard safety checks can never be bypassed
- If
acceptEditsmode considers something safe, thenautomode should too — avoiding unnecessary classifier API calls - Allowlisted tools (such as read-only tools) pass through directly
- Only operations that genuinely require judgment are sent to the classifier
Consecutive Denial Tracking
auto mode also has a consecutive denial tracking mechanism (denialTracking). When the classifier consecutively denies multiple operations, the system falls back to interactive prompting. This prevents an overly conservative classifier from completely stalling the workflow.
Hook Pre-screening Mechanism
Permission hooks are key to Claude Code's extensibility. Users can configure custom PermissionRequest hooks to add additional logic beyond the standard permission checks.
Hooks' Position in the Pipeline
The timing of hook execution depends on the runtime mode:
- Interactive mode: Hooks race in parallel with the user dialog (fire-and-forget async)
- Coordinator mode: Hooks execute serially before the dialog is displayed
- Swarm mode: Hooks don't participate directly (executed on the leader side)
Hooks can do three things:
- Allow (
behavior: 'allow'): Skip user confirmation and execute directly. Can includeupdatedPermissionsto persist new rules. - Deny (
behavior: 'deny'): Block execution. Can setinterrupt: trueto abort the entire session. - Make no decision (no return or skip): Let other mechanisms continue processing.
Hook Handling for Headless Agents
For headless agents where shouldAvoidPermissionPrompts is true (background agents running without UI), hooks are the only path to automated approval. If hooks don't make a decision, the operation is automatically denied:
Permission Queue and React State Bridging
The permission dialog isn't a simple window.confirm — it's a full React component that supports editing input, selecting persistence options, providing feedback, and more. The permission system interfaces with React state through the PermissionQueueOps interface:
This is an elegant "bridging" design — the permission logic has zero dependency on React. PermissionQueueOps is a generic interface, and any system that can provide push/remove/update operations can replace the React implementation. This also means the permission system can be ported to other UI frameworks or entirely UI-less environments.
recheckPermission: Hot-reloading Permissions
The permission dialog also has a special recheckPermission callback that allows permissions to be re-evaluated while the dialog is displayed:
This solves a practical scenario: a user switches permission modes on claude.ai (Bridge) — say from default to bypassPermissions — and the permission dialog currently displayed on the CLI side should immediately disappear, with the operation continuing automatically. recheckPermission is called when a mode switch event fires, re-evaluates permissions, and if the new mode allows the operation, auto-approves it and dismisses the dialog.
Visual Feedback for Classifier Auto-Approval
When the classifier auto-approves an operation before the user makes a decision, the interactive handler displays a brief checkmark:
This design considers user perception:
- Terminal focused: The user is likely looking at the screen, so give them 3 seconds to notice the operation was auto-approved
- Terminal unfocused: The user isn't watching, so 1 second is enough
- Manually dismissible: Pressing Esc closes it immediately without blocking the workflow
- Abort-safe: If a sibling abort occurs during the checkmark display (e.g., another tool fails), the checkmark dialog is properly cleaned up
Transferable Patterns: Building Layered Permission Systems for AI Applications
The permission system design in Claude Code can be distilled into a set of general-purpose architectural patterns for AI application permissions. Here are the key design principles and their corresponding implementation strategies.
Principle 1: Layered Evaluation, Deny First
Each layer does one thing, and the evaluation order is fixed. Deny rules come first to ensure the security baseline cannot be bypassed. This pattern can be directly applied to any AI application requiring permission control.
Principle 2: Immutable State + Atomic Decisions
Permission state is protected with DeepImmutable, and the decision process is guaranteed atomic with ResolveOnce. When your system has multiple asynchronous sources that might simultaneously make decisions (users, automated systems, remote approvers), the claim() pattern is a lightweight yet reliable solution.
Principle 3: Secure Defaults + Explicit Relaxation
The system starts in its "safest" state. Every relaxation requires an explicit action — a user clicking "Always allow," an admin configuring a policy, or a command-line argument being passed. This ensures security is never accidentally weakened due to missing configuration.
Principle 4: Rule Source Tracking
Every rule carries source information (which configuration file, which layer). This is used not only for priority ordering but also for auditing — when a permission issue arises, you can pinpoint exactly "where this rule came from."
Principle 5: Handler Separation
Different runtime environments have different permission requirements. Claude Code's three handler patterns — interactive (competitive), coordinator (serial pre-screening), and swarm (mailbox forwarding) — demonstrate how to adapt a single rule system to different execution environments.
The core abstraction is PermissionContext: it encapsulates all permission operations (logging, persistence, queue management), letting handlers focus solely on flow control. When adding a new runtime environment, you only need to implement a new handler function — no modifications to the rule evaluation logic are required.
Principle 6: Progressive Trust
The auto mode classifier doesn't call AI evaluation on every operation — it first uses fast paths to filter out clearly safe and clearly dangerous operations:
Each additional fast-path layer eliminates a batch of unnecessary API calls. This pattern applies to any system that uses AI for runtime decision-making.
Practical Architecture Recommendations
If you're building a permission system for an AI application, you can start with this minimal architecture:
Then incrementally add features as needed:
- Immutable state protection: Prevent runtime modifications
- Atomic racing: When there are multiple asynchronous decision sources
- Source tracking: When auditing or debugging is needed
- Classifier integration: When the action space is too large for rules to cover
- Handler separation: When there are multiple runtime environments
Conclusion
Claude Code's permission system is a multi-layered defense system. Its core insight is that permissions are not a binary choice (allow/deny), but a continuous spectrum across multiple dimensions.
- Mode dimension: From
bypassPermissionstodontAsk, users choose their own risk tolerance - Rule dimension: From blanket tool-level to content-level, supporting fine-grained permission control
- Source dimension: From policies to sessions, multiple configuration layers stack on top of each other
- Role dimension: The main agent, coordinator, and swarm workers each have distinct handling flows
- Temporal dimension: The classifier, Hooks, and user interaction race along the timeline, with the first to decide winning
The engineering implementation of this system contains many patterns worth learning: DeepImmutable protects state safety, ResolveOnce guarantees atomicity, handler separation ensures extensibility, and progressive fast paths reduce unnecessary computation. These patterns are not limited to permission systems — any system involving multi-source asynchronous decision-making can draw from them.
Finally, returning to the opening question: where should the permission boundary be drawn? Claude Code's answer is — don't draw a fixed line; instead, provide a toolkit that lets each user draw their own. This is perhaps the most pragmatic approach to AI tool safety today.