Skip to content

Chapter 7 — The Permission & Security Model

What You'll Learn

By the end of this chapter, you will be able to:

  • Name and distinguish all seven PermissionMode values, including the two internal-only modes that never appear in configuration files
  • Explain the three-state PermissionBehavior model (allow, deny, ask) and articulate exactly when each state is produced
  • Read a PermissionDecisionReason value and reconstruct the decision path that produced it, using it as an audit trail
  • Trace any tool call through the eleven-step hasPermissionsToUseToolInner() decision engine, predicting the output at each step
  • Understand the outer hasPermissionsToUseTool() wrapper and explain how dontAsk mode and auto mode transform an ask result into something else
  • Follow the four paths inside the useCanUseTool() React hook that handle the ask state returned from the outer wrapper
  • Write settings.json rules using all three rule syntaxes — exact, prefix, and wildcard — and correctly predict their precedence

7.1 Permission Modes: The Seven PermissionMode Values

Claude Code runs in one of several permission modes. The mode controls the default disposition of the entire permission system: how aggressively the agent assumes it can act, and how much user confirmation is required. The type is defined in src/types/permissions.ts.

Five modes are "external" — they can appear in configuration files, CLI flags, or be set by enterprise policy:

ModeDescriptionTypical Activation
defaultThe standard interactive mode. The system asks the user before any tool call that does not have an explicit allow rule. This is the mode most users see when running claude interactively.Default when no flag is set
acceptEditsFile edits (writes, patches) are automatically accepted without confirmation. Bash commands and other side-effecting tools still require the user's approval for calls that lack an allow rule.--accept-edits CLI flag
bypassPermissionsAll permission checks are skipped. Every tool call is allowed unconditionally, regardless of rules or safety checks. This is the "dangerous" mode referenced by the flag --dangerously-skip-permissions.--dangerously-skip-permissions
dontAskWhen the decision engine would normally return ask (meaning "show the user a dialog"), dontAsk mode silently converts that result to deny instead. No dialog is shown; the tool call is rejected without user interaction.Programmatic / headless scenarios
planRead-only mode. Write tools, Bash, and other destructive tools are disallowed. The agent can inspect the codebase and formulate a plan, but cannot execute it.--plan CLI flag

Two additional modes are internal runtime modes that are never directly set in configuration:

ModeDescriptionHow It Arises
autoThe AI classifier (the "YoloClassifier") replaces the interactive dialog. When the decision engine would return ask, the classifier evaluates the tool call and emits allow or deny based on its own analysis. This mode is activated by the TRANSCRIPT_CLASSIFIER feature flag.Feature-flagged, set at runtime
bubbleUsed when a sub-agent needs to surface a permission decision up to its parent coordinator. The sub-agent does not make the decision itself; it forwards the question up the chain.Multi-agent coordinator/worker topology

Understanding which mode the session is in is the first thing any reader of the permission code should check, because the mode determines which branches of the decision engine are even reachable.


7.2 Three-State Behavior: allow, deny, ask

Every permission decision in the codebase resolves to one of exactly three states, typed as PermissionBehavior in src/types/permissions.ts:44:

typescript
export type PermissionBehavior = 'allow' | 'deny' | 'ask'

These three states are not merely suggestions — they are binding outputs of the decision engine that determine what happens next in the agentic loop.

allow means the tool call proceeds immediately with no user interaction. The tool's call() method runs, and the result is injected back into the conversation. From a user perspective, an allow decision is invisible.

deny means the tool call is rejected. The loop generates a synthetic tool result message explaining that permission was denied, and the model sees that rejection as context for its next turn. The deny result carries a message string and a decisionReason — together these form the audit record of why the call was blocked. Importantly, the agentic loop records denied calls in the permissionDenials field of session state; the QueryEngine exposes this as part of the final SDKResultMessage so callers can inspect the denial history.

ask means neither side has authority to decide alone. In interactive mode, ask triggers the display of a permission dialog in the terminal UI, asking the user whether to allow or deny this specific invocation — optionally offering "always allow" or "always deny" to add a persistent rule. In headless mode, ask is transformed into deny (either by dontAsk mode or by the shouldAvoidPermissionPrompts flag), because there is no human to respond to the dialog.

The ask state is also not quite the same as "undecided." It is the system's way of saying: "automated checks have not resolved this; a human (or a classifier acting as a human proxy) must decide." The path from ask to a final outcome is where most of the interesting logic lives, and it is the subject of sections 7.5 and 7.6.


7.3 The Audit Trail: PermissionDecisionReason

Every PermissionDecision carries a decisionReason field of type PermissionDecisionReason. This union type with eleven variants acts as a structured audit log. When you look at a denial in the session's permissionDenials array, the decisionReason tells you exactly why the decision was made.

The full union is defined at src/types/permissions.ts:271:

Variant typeTrigger ConditionConcrete Example
ruleA permission rule matched the tool call. The rule field contains the full PermissionRule including its source, behavior, and value.The user has "deny": ["Bash(rm -rf *)"] in their project settings — a delete command matches and is denied.
modeThe current PermissionMode directly determined the outcome, without any rule match.bypassPermissions mode produces allow with this reason; dontAsk mode produces deny with this reason.
subcommandResultsThe BashTool decomposed a compound command into sub-commands and checked each one separately. The reasons map is keyed by sub-command string.git add . && npm publish is split; git add . is allowed but npm publish is denied via a deny rule.
permissionPromptToolAn external PermissionPromptTool (an MCP-based permission delegate) returned a decision. The toolResult contains the raw response from that external tool.An enterprise audit server is registered as a PermissionPromptTool and rejects a sensitive file read.
hookA PermissionRequest hook script determined the outcome. The hookName, optional hookSource, and optional reason explain which hook and why.A pre-permission hook script checks a ticket system and denies a file write because no open ticket exists.
asyncAgentThe session is running as a headless agent (shouldAvoidPermissionPrompts: true) and the decision engine reached ask with no automated resolution. The call is auto-denied.A programmatic QueryEngine call with no permission hooks reaches a tool call that would normally show a dialog.
sandboxOverrideThe sandbox layer intervened. Either the command is in the sandbox's excluded-command list, or the sandbox's "dangerous disable" flag is set. The reason is 'excludedCommand' or 'dangerouslyDisableSandbox'.The sandbox lists sudo as an excluded command; any sudo-prefixed Bash call is denied.
classifierThe AI classifier (YoloClassifier in auto mode, or BASH_CLASSIFIER in the speculative path) made the decision. The classifier name and a human-readable reason are included.In auto mode, the transcript classifier approves a git commit -m "fix typo" command.
workingDirThere is an issue with the working directory context — for example, the requested path is outside all known working directories.A FileRead for /etc/passwd is denied because it is outside the project root and no additional working directory covers it.
safetyCheckThe path or command touches a protected location: .git/, .claude/, or shell configuration files such as .bashrc. The classifierApprovable boolean indicates whether the AI classifier is permitted to override this check.An attempt to overwrite .git/config is caught by the safety check and denied even in bypassPermissions mode.
otherA catch-all for decisions that do not fit any structured category. The reason is a free-form string.A tool implements a one-off permission check internal to its checkPermissions() method and has no better type to use.

The safetyCheck variant is worth particular attention. When classifierApprovable is false, the safety check is absolute — it cannot be overridden by bypassPermissions mode, by rules, or by the classifier. This is the system's hard boundary protecting the repository's own configuration.


7.4 The Core Decision Engine: hasPermissionsToUseToolInner()

The heart of the permission system is hasPermissionsToUseToolInner() in src/utils/permissions/permissions.ts. It is an async function that accepts a Tool, its input, and the current ToolUseContext, and returns a Promise<PermissionDecision>.

The function runs exactly eleven logical steps, in order. Each step either produces a final PermissionDecision and returns early, or falls through to the next step. No step is skipped unless a prior step returned.

Step 1: Rule and Safety Gates

The function begins with an abort signal check (Step 0). This is a defensive guard: if the user has cancelled the current operation, throwing an AbortError immediately prevents stale permission checks from running against outdated state.

Step 1a consults the deny rules. getDenyRuleForTool() looks for any rule in alwaysDenyRules whose toolName and optional ruleContent match this particular tool and input. If a deny rule matches, execution stops immediately with behavior: 'deny' and a decisionReason of type rule. Deny rules have the highest priority of any rule type — they cannot be overridden by allow rules or by mode.

Step 1b checks for a whole-tool ask rule (for example, alwaysAsk: ["Bash"] in settings, which forces a confirmation dialog before every Bash call). There is one exception: if the BashTool is running in a sandbox environment that can auto-allow the call, the ask rule is bypassed and execution continues to Step 1c. This exception exists because the sandbox itself already provides isolation, making an explicit user confirmation redundant for low-risk commands.

Step 1c delegates to tool.checkPermissions(). This is content-aware logic implemented per tool. For FileReadTool, this checks whether the path is within an allowed working directory. For BashTool, this runs the shell rule matching, the speculative classifier check, and the subcommand decomposition. The result is stored but not yet returned — the next three steps apply important filters to it.

Step 1d returns immediately if tool.checkPermissions() produced deny. A tool that denies at the content level cannot be overridden by any allow rule or mode.

Step 1e handles tools that have requiresUserInteraction() returning true and whose content check produced ask. This signals that the tool has inherent UI requirements — the dialog is not just a permission gate but an integral part of the tool's operation.

Steps 1f and 1g are the two "bypass-immune" exits. Step 1f catches the case where checkPermissions() produced an ask with decisionReason.type === 'rule' and decisionReason.rule.ruleBehavior === 'ask'. This means a content-specific ask rule matched (such as "alwaysAsk": ["Bash(npm publish:*)"]). This result is returned immediately and cannot be converted to allow by bypassPermissions mode. Step 1g does the same for safetyCheck results. The .git/ and .claude/ directory protections are hard limits that even --dangerously-skip-permissions cannot override.

Step 2: Mode and Allow-Rule Fast Paths

Once the tool-level and content-level deny/ask gates have all been passed, Steps 2a and 2b apply the two allow fast paths.

Step 2a handles bypassPermissions mode. If the session is in bypassPermissions, or if it is in plan mode and the isBypassPermissionsModeAvailable flag is set (meaning bypass is available but plan is the active mode), execution jumps straight to allow. The decisionReason records type: 'mode' so the audit trail shows this was a mode-level bypass, not a rule match.

Step 2b checks for a whole-tool allow rule. toolAlwaysAllowedRule() returns the first matching rule in alwaysAllowRules. If found, the result is allow with decisionReason.type === 'rule'.

Step 3: Default to ask

If none of the prior steps returned, Step 3 is reached. At this point the tool call has passed all deny checks, bypassed no early allows, and has no matching allow rule. The only remaining question is what checkPermissions() returned.

If the result is passthrough (the tool's way of saying "I have no content-specific opinion; use the default"), Step 3 converts it to ask with a generic permission request message. If the result is already ask (the tool has a specific opinion and a specific message), it is returned as-is. Either way, the final output of hasPermissionsToUseToolInner() in this case is ask.


7.5 The Outer Wrapper: hasPermissionsToUseTool()

hasPermissionsToUseTool() is the public entry point of the permission system. It calls hasPermissionsToUseToolInner() and then applies two additional transformations to the result if the inner function returned ask.

7.5.1 dontAsk Mode Conversion

The first transformation is simple. If the current mode is dontAsk, any ask result from the inner function is converted to deny:

typescript
// src/utils/permissions/permissions.ts (conceptual)
if (innerResult.behavior === 'ask' && mode === 'dontAsk') {
  return { behavior: 'deny', decisionReason: { type: 'mode', mode: 'dontAsk' }, message: ... }
}

This is the correct behavior for automation scenarios where you want the agent to operate only within its pre-approved rule set and silently reject anything outside it, rather than blocking on an unanswerable dialog.

7.5.2 auto Mode and the YoloClassifier

When the mode is auto, the outer wrapper runs the AI classifier pipeline before deciding whether to show a dialog or deny. The pipeline has four ordered checks:

First, if the inner result has decisionReason.type === 'safetyCheck' and classifierApprovable is false, the classifier is skipped entirely. Safety-check denials with classifierApprovable: false are absolute, and even the classifier cannot override them. The outer wrapper either returns ask (to show a dialog in interactive mode) or deny (if shouldAvoidPermissionPrompts is set).

Second, there is an acceptEdits fast path. Certain tools explicitly support acceptEdits mode, and if the current mode allows it, the outer wrapper returns allow immediately without invoking the classifier. This avoids classifier latency for the common case of file edits, which are usually benign.

Third, the outer wrapper checks denial tracking. The system maintains a count of consecutive classifier-produced denials for this session. If the count exceeds a configured DENIAL_LIMITS threshold, the classifier is considered unreliable for this context, and the outer wrapper falls back to the interactive dialog. This prevents a runaway denial loop from silently blocking all tool calls.

Fourth, the YoloClassifier itself runs. It evaluates the conversation transcript and the proposed tool call, then returns one of several outcomes:

  • unavailable with iron_gate_closed: true: The classifier service is down and the system is configured to fail closed. The result is deny.
  • unavailable with iron_gate_closed: false: The classifier service is down but the system is configured to fail open. The result falls back to the interactive dialog.
  • transcriptTooLong: In headless mode, this throws an AbortError (the transcript cannot be truncated without losing safety context). In interactive mode, the result falls back to the dialog.
  • shouldBlock: true: The classifier decided the tool call should be blocked. The result is deny, and the denial counter is incremented.
  • shouldBlock: false: The classifier approved the tool call. The result is allow, and the denial counter is reset to zero.

The decisionReason for any classifier-produced result uses type: 'classifier' with the classifier name and a textual reason.


7.6 The React Hook: useCanUseTool()

useCanUseTool() in src/hooks/useCanUseTool.tsx is the bridge between the permission decision engine and the terminal UI. It is called after hasPermissionsToUseTool() has returned ask — the automated layers have exhausted their options, and a decision must come from somewhere interactive or from a higher authority.

The hook routes the ask through one of four paths, checked in order:

Path A: Coordinator Worker Mode

When awaitAutomatedChecksBeforeDialog is true in ToolPermissionContext, the session is running as a coordinator worker: a multi-agent role where another orchestrating process may have the authority to make the decision. The hook calls handleCoordinatorPermission(), which runs the PermissionRequest hooks and the classifier in serial. If either one produces a definitive allow or deny, that decision is used. If both come back without resolution — returning null — execution falls through to Path B.

Path B: Swarm Worker Forwarding

If the session is a swarm worker (a sub-agent in a parallel task group), the hook calls handleSwarmWorkerPermission(), which writes a message to the swarm leader's mailbox: "I need a permission decision for this tool call." The leader processes the request through its own permission system, writes the decision back, and the worker reads it. If the leader provides a decision, the worker uses it. If the leader returns nothing (for example, if the leader itself is blocked or the mailbox times out), the worker falls through to Path C.

Path C: Speculative Classifier (2-Second Race)

Path C applies only to BashTool calls when the BASH_CLASSIFIER feature is enabled. The speculative mechanism is interesting: when the model begins streaming a Bash command, the classifier is started immediately in the background — before the permission check even begins. By the time useCanUseTool() reaches Path C, the classifier may already have a result.

The hook calls peekSpeculativeClassifierCheck(command) to retrieve the in-progress promise, then races it against a 2-second timeout:

typescript
// src/hooks/useCanUseTool.tsx (conceptual)
const raceResult = await Promise.race([
  speculativePromise.then(r => ({ type: 'result', result: r })),
  new Promise(res => setTimeout(res, 2000, { type: 'timeout' })),
])

If the race completes with type: 'result', the hook checks whether the classifier's result matches the command and whether confidence is 'high'. Both conditions must hold. A 'medium' or 'low' confidence result falls through to the dialog even if it nominally approved the call. If the race times out — meaning the classifier took more than 2 seconds — execution falls through to Path D. The classifier may still be running in the background and will complete, but its result will not be used for this specific dialog decision.

Path D: Interactive Dialog

The default path. handleInteractivePermission() renders the terminal dialog using Ink, presenting the user with the tool name, the specific command or path, and buttons for "Allow once," "Always allow," "Deny once," and "Always deny." While the dialog is visible, hooks and the classifier continue running in the background. If an automated check resolves before the user clicks, the dialog is dismissed and the automated decision is used. If the user responds first, the user's decision is final.


7.7 Rule-Based Permissions

Rules are the primary tool for configuration. They allow both users and enterprises to define standing policies that apply without any per-call dialog.

7.7.1 Rule Syntax (exact, prefix, wildcard)

All Bash rules are parsed by src/utils/permissions/shellRuleMatching.ts into one of three syntactic forms:

typescript
// src/utils/permissions/shellRuleMatching.ts
export type ShellPermissionRule =
  | { type: 'exact';    command: string  }   // "git status"
  | { type: 'prefix';   prefix: string   }   // "npm:*"  → prefix "npm"
  | { type: 'wildcard'; pattern: string  }   // "git *"

Exact rules match only if the entire command string (after trimming) is an exact character-for-character match. The rule string "Bash(git status)" in settings.json produces an exact rule for git status. This is the most precise form and appropriate for commands where you want to allow one specific invocation.

Prefix rules use the legacy toolName:* syntax. The rule string "Bash(npm:*)" produces a prefix rule for npm. A prefix rule matches any command that starts with the prefix followed by whitespace, or that is exactly the prefix itself. This form was the original parameterized rule syntax and remains supported for backwards compatibility.

Wildcard rules are the most powerful and general. The rule string "Bash(git *)" produces a wildcard rule with pattern git *. The * token matches any sequence of non-newline characters, allowing expressive rules like "git commit -m *" (match any git commit with a message) or "rm -rf /tmp/*" (match deletions inside /tmp).

For non-Bash tools, the rule syntax is simpler:

  • "FileRead" — applies to the entire FileRead tool, all inputs
  • "mcp__server1" — applies to all tools from the MCP server named server1
  • "mcp__server1__toolname" — applies to one specific MCP tool

7.7.2 Rule Sources and Precedence

Rules can originate from eight sources, defined in PermissionRuleSource:

SourceLocationNotes
flagSettingsEnterprise/MDM enforcedHighest effective authority; cannot be overridden by any user or project setting
policySettingsPolicy configurationOrganizational policy layer
cliArgCLI flags passed at invocation--allow-tool Bash(git *)
userSettings~/.claude/settings.jsonPer-user global settings
projectSettings.claude/settings.jsonPer-project settings, checked into source control
localSettings.claude/settings.local.jsonPer-project local overrides, typically gitignored
commandAdded at runtime during conversationVia addPermissionRule during a session
sessionSession-scoped temporary rulesAdded via the grantTemporaryPermission flow

The precedence order for deny vs. allow is: deny rules always win over allow rules regardless of source order. Within the same behavior type (all allow rules, or all deny rules), the first matching rule wins, and sources are evaluated in the order listed above (flag settings first, session last).

One important implication: a flagSettings deny rule cannot be overridden by a localSettings allow rule. Enterprise restrictions are enforced unconditionally. Conversely, a session allow rule can override a userSettings ask rule, because ask-to-allow conversion is handled later in the pipeline, and both apply to distinct behaviors.


7.8 Hook-Based Permissions: PermissionRequest

In addition to declarative rules, Claude Code supports imperative permission logic via PermissionRequest hooks. A hook is an external script (shell command, Node.js program, Python script) that receives a JSON payload describing the pending tool call and writes a JSON decision to stdout.

Hooks are configured in settings.json under the hooks key:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/local/bin/check-bash-policy",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

The hook receives a payload containing the tool name, the parsed input, the current working directory, and the session ID. It should exit with code 0 and write one of the following JSON responses:

json
{ "decision": "allow", "reason": "Command is on the approved list" }
{ "decision": "deny", "reason": "Command modifies protected config files" }
{ "decision": "ask", "reason": "Ambiguous — escalate to user" }

If the hook exits with a non-zero code, times out, or writes invalid JSON, the permission system treats the result as ask and falls through to the next path (or to the dialog). Hook failures do not produce automatic denials — the fail-open design prevents a buggy hook from completely blocking tool use.

When a hook produces a decision, the resulting PermissionDecision carries decisionReason: { type: 'hook', hookName: '...', hookSource: '...', reason: '...' }. This ensures the audit trail always records which specific hook made the call.

Hooks are powerful for organizational policy enforcement: you can implement ticket-gate checks ("is there an open JIRA ticket authorizing this change?"), rate-limiting ("has this session already run 50 npm commands?"), or environment-awareness ("is this a production working directory?"). The hook mechanism is how the permission system extends from a static rule set to a fully programmable policy engine.


7.9 settings.json Configuration Guide

The following is a complete, annotated settings.json example covering the most common permission configuration patterns. The file lives at either ~/.claude/settings.json (user-global) or .claude/settings.json (project-scoped).

json
{
  "permissions": {
    "allow": [
      "Bash(git status)",
      "Bash(git diff)",
      "Bash(git log)",
      "Bash(git add *)",
      "Bash(git commit -m *)",
      "Bash(git push)",
      "Bash(npm:*)",
      "Bash(yarn:*)",
      "FileRead",
      "FileEdit",
      "mcp__filesystem"
    ],

    "deny": [
      "Bash(rm -rf *)",
      "Bash(sudo:*)",
      "Bash(curl * | bash)",
      "Bash(wget * | sh)",
      "mcp__network__httpRequest"
    ],

    "ask": [
      "Bash(git push --force*)",
      "Bash(git rebase*)",
      "Bash(npm publish*)",
      "Bash(docker run*)"
    ]
  }
}

A few design notes on this example:

The allow list uses a mix of exact rules ("Bash(git status)"), wildcard rules ("Bash(git add *)"), and tool-level rules ("FileRead", "mcp__filesystem"). The tool-level rules for FileRead and FileEdit grant blanket approval for all file reading and editing, which is appropriate for most development workflows. The mcp__filesystem rule covers all tools from an MCP server named filesystem.

The deny list uses wildcards to block dangerous patterns rather than exact commands. "Bash(rm -rf *)" blocks any rm -rf invocation regardless of the path argument. "Bash(sudo:*)" uses the prefix syntax to block all sudo-prefixed commands. The pipe-to-shell patterns (curl * | bash) protect against a common supply-chain attack vector.

The ask list is for high-stakes operations that should still be possible but require confirmation each time. git push --force is allowed with an interactive prompt; git push (without --force) is in the allow list and requires no prompt.

For enterprise deployments, the same structure applies under flagSettings, and those rules cannot be overridden by users:

json
{
  "flagSettings": {
    "permissions": {
      "deny": [
        "Bash(curl*)",
        "Bash(wget*)",
        "mcp__externalApi"
      ]
    }
  }
}

To configure a PermissionRequest hook that gates all file writes:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "FileWrite",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /opt/policy/file-write-auditor.py",
            "timeout": 3000
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/opt/policy/bash-auditor",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

To enable auto mode for a project (requires the TRANSCRIPT_CLASSIFIER feature to be available in your build):

json
{
  "permissionMode": "auto"
}

To enable acceptEdits mode so that file edits do not require confirmation but Bash still does:

json
{
  "permissionMode": "acceptEdits"
}

Key Takeaways

The permission system in Claude Code is a multi-layered, ordered decision pipeline. Each layer has a specific responsibility and a specific position in the evaluation order.

The decision engine hasPermissionsToUseToolInner() runs eleven steps in strict sequence. Deny rules come first and cannot be overridden. Safety checks on .git/ and .claude/ paths come next and are equally immune to bypass. Only after all deny and safety checks pass does the function look at bypassPermissions mode and allow rules. This ordering is intentional: it means the "most restrictive" checks always win over the "most permissive" modes.

The PermissionDecisionReason union with its eleven variants is not merely a debugging convenience. It is a first-class audit mechanism. Every decision — whether allow, deny, or ask — carries a structured reason that explains exactly which layer made the call. The permissionDenials array surfaced in SDKResultMessage exposes the full denial history to programmatic callers.

The useCanUseTool() hook handles the ask state through a four-path priority chain: coordinator delegation, swarm forwarding, speculative classifier (with a strict 2-second timeout), and finally the interactive dialog. Understanding this chain explains the observable behavior of permission prompts: why some Bash commands are approved without a dialog appearing, why a multi-agent run may produce different confirmation behavior than a single-agent run, and why the first call to a new command pattern is sometimes slower than subsequent calls.

Rule syntax has three forms — exact, prefix, and wildcard — and they differ in their scope and specificity. Wildcard rules with * are the most expressive but also the easiest to accidentally over-specify (allowing too much) or under-specify (not matching the actual command). When writing rules, exact rules are safest for known-safe invocations; deny rules should prefer wildcards to catch variant spellings.

The hook system is the escape hatch for policy requirements that cannot be expressed as static rules. If you need dynamic, context-aware permission logic — checking external systems, enforcing rate limits, or gating based on environment variables — hooks are the right mechanism. Rules and hooks compose: a tool call must pass both the rule layer (no deny rule matches) and the hook layer (no hook returns deny) to be allowed.

Finally, the distinction between the external permission modes (default, acceptEdits, bypassPermissions, dontAsk, plan) and the internal modes (auto, bubble) matters in practice. The internal modes are not user-facing configuration options; they are runtime states produced by the multi-agent infrastructure and the feature-flag system. Encountering mode: 'auto' in a decisionReason means the classifier made the call; encountering mode: 'bubble' means the decision was forwarded to a parent agent. Both are observable in the audit trail but neither is directly settable by end users in settings.json.

Built for learners who want to read Claude Code like a real system.