claude-code/learn/04-permission-system.md

11 KiB

4. Permission System

How Claude Code prevents an AI from doing dangerous things — a multi-layered defense.


Why Permissions Matter

Claude Code can run arbitrary bash commands, write to any file, and make network requests. Without a permission system, a single misguided model response could rm -rf / your entire system.

The permission system is a chain of checks — if any link denies, the tool doesn't run.


The Permission Flow

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#dc3545', 'primaryBorderColor': '#dc3545'}}}%%
flowchart TD
    ENTRY["Tool call arrives"]:::start

    DR{"Deny rules?<br/>blanket deny, pattern match"}
    AR{"Allow rules?<br/>always-allow from settings"}
    TSP{"tool.checkPermissions?<br/>tool-specific logic"}
    HOOK{"PreToolUse hooks?<br/>user-defined scripts"}
    CLASS{"Auto-mode classifier?<br/>transcript safety analysis"}
    DIALOG{"User permission dialog<br/>Y / n / always-allow"}

    ALLOW["ALLOW<br/>execute tool"]:::allow
    DENY["DENY<br/>return error to model"]:::deny

    ENTRY --> DR

    DR -->|"matched deny rule"| DENY
    DR -->|"no match"| AR

    AR -->|"matched allow rule"| ALLOW
    AR -->|"no match"| TSP

    TSP -->|"tool says allow"| HOOK
    TSP -->|"tool says deny"| DENY

    HOOK -->|"hook approves"| ALLOW
    HOOK -->|"hook denies"| DENY
    HOOK -->|"no decision"| CLASS

    CLASS -->|"classified safe"| ALLOW
    CLASS -->|"classified unsafe"| DIALOG
    CLASS -->|"not in auto-mode"| DIALOG

    DIALOG -->|"user accepts"| ALLOW
    DIALOG -->|"user rejects"| DENY
    DIALOG -->|"always allow"| AR

    classDef start fill:#1a2d4a,stroke:#4a9eff,color:#e0e0e0,stroke-width:2px
    classDef allow fill:#1b3a1b,stroke:#28a745,color:#e0e0e0,stroke-width:2px
    classDef deny fill:#4a1a1a,stroke:#dc3545,color:#e0e0e0,stroke-width:2px

Layer 1: Deny Rules

First check. Highest priority. Cannot be overridden.

Deny rules are pattern-matched against tool name and input. If a deny rule matches, the tool is immediately rejected — no further checks run.

Sources of deny rules:

  • settings.json — User-configured
  • CLAUDE.md — Project-level rules
  • Organization policy — Enterprise MDM settings

Example deny rules:

{
  "alwaysDenyRules": {
    "settings": [
      { "tool": "Bash", "pattern": "rm -rf" },
      { "tool": "FileWrite", "pattern": "/etc/*" }
    ]
  }
}

Permission Matching

Tools can implement preparePermissionMatcher() for custom pattern matching:

// Bash tool: "git *" matches any git command
preparePermissionMatcher(input) {
  return async (pattern) => minimatch(input.command, pattern)
}

Layer 2: Allow Rules

If no deny rule matched, check if an allow rule grants automatic approval.

Allow rules come from:

  • User clicking "always allow" in the permission dialog
  • settings.json configuration
  • Slash command grants (e.g., /plan exit grants specific operations)
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#28a745', 'primaryBorderColor': '#28a745'}}}%%
flowchart LR
    subgraph Sources["Allow Rule Sources"]
        S1["settings.json<br/>user config"]
        S2["CLAUDE.md<br/>project rules"]
        S3["User dialog<br/>always-allow choice"]
        S4["Command grants<br/>plan mode exit"]
    end

    MERGE["ToolPermissionContext<br/>alwaysAllowRules"]:::merge

    CHECK{"Pattern match<br/>against tool + input"}:::check

    ALLOW["Auto-approved"]:::allow
    NEXT["Continue to<br/>next layer"]:::next

    S1 --> MERGE
    S2 --> MERGE
    S3 --> MERGE
    S4 --> MERGE

    MERGE --> CHECK
    CHECK -->|"match"| ALLOW
    CHECK -->|"no match"| NEXT

    classDef merge fill:#1a2d4a,stroke:#4a9eff,color:#e0e0e0,stroke-width:2px
    classDef check fill:#2d2d0d,stroke:#ffc107,color:#e0e0e0,stroke-width:2px
    classDef allow fill:#1b3a1b,stroke:#28a745,color:#e0e0e0,stroke-width:2px
    classDef next fill:#333,stroke:#888,color:#e0e0e0,stroke-width:1px

Layer 3: Tool-Specific Permissions

Each tool implements checkPermissions(input, context):

// Example: FileRead defaults to allow (it's read-only)
checkPermissions: () => Promise.resolve({ behavior: 'allow' })

// Example: Bash checks if the command is read-only
checkPermissions: (input) => {
  if (isReadOnlyCommand(input.command)) {
    return { behavior: 'allow' }
  }
  return { behavior: 'askUser', message: `Run: ${input.command}` }
}

The result can be:

  • { behavior: 'allow' } — Approved
  • { behavior: 'deny', message } — Rejected with reason
  • { behavior: 'askUser', message } — Escalate to user prompt

Layer 4: PreToolUse Hooks

User-defined scripts that run before tool execution. Configured in settings.json or CLAUDE.md:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "/path/to/safety-check.sh"
      }
    ]
  }
}

Hook scripts receive the tool name and input as JSON on stdin. They can:

  • Approve (exit 0, no output)
  • Deny (exit non-zero, stderr has reason)
  • Modify input (exit 0, stdout has modified JSON)

Layer 5: Auto-Mode Classifier

In --auto mode, a classifier examines the conversation transcript to determine if a tool call is safe:

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#17a2b8', 'primaryBorderColor': '#17a2b8'}}}%%
flowchart TD
    TC["Tool call in auto-mode"]:::start

    BUILD["Build classifier input<br/>tool.toAutoClassifierInput(input)"]:::step
    TRANSCRIPT["Append recent transcript<br/>for context"]:::step
    CLASSIFY["Run safety classifier<br/>is this operation safe?"]:::step

    SAFE{"Classified as?"}:::check

    ALLOW["Auto-approved<br/>no user prompt"]:::allow
    PROMPT["Escalate to<br/>user dialog"]:::deny

    TC --> BUILD --> TRANSCRIPT --> CLASSIFY --> SAFE
    SAFE -->|"safe"| ALLOW
    SAFE -->|"unsafe"| PROMPT

    classDef start fill:#1a2d4a,stroke:#4a9eff,color:#e0e0e0,stroke-width:2px
    classDef step fill:#0d4f4f,stroke:#17a2b8,color:#e0e0e0,stroke-width:2px
    classDef check fill:#2d2d0d,stroke:#ffc107,color:#e0e0e0,stroke-width:2px
    classDef allow fill:#1b3a1b,stroke:#28a745,color:#e0e0e0,stroke-width:2px
    classDef deny fill:#4a1a1a,stroke:#dc3545,color:#e0e0e0,stroke-width:2px

Each tool provides toAutoClassifierInput() which returns a compact representation for the classifier. Security-irrelevant tools return '' to skip classification.


Layer 6: User Permission Dialog

The last resort — ask the human:

╭────────────────────────────────────────╮
│  Claude wants to run:                  │
│                                        │
│  $ npm install lodash                  │
│                                        │
│  (Y)es  ·  (n)o  ·  (a)lways allow    │
╰────────────────────────────────────────╯

Choosing "always allow" adds a permanent allow rule.


Permission Modes

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#fd7e14', 'primaryBorderColor': '#fd7e14'}}}%%
flowchart TB
    START(["Session Start"]):::neutral --> DEFAULT

    DEFAULT["DEFAULT MODE<br/>Prompt user on every write tool"]:::mode1
    PLAN["PLAN MODE<br/>Read tools auto-approved<br/>Write tools require approval"]:::mode2
    AUTO["AUTO MODE<br/>Classifier decides safety<br/>Safe = allow, Unsafe = prompt"]:::mode3
    BYPASS["BYPASS MODE<br/>Everything auto-approved<br/>No permission checks"]:::mode4

    DEFAULT -->|"/plan command<br/>or model enters plan"| PLAN
    PLAN -->|"model exits<br/>plan mode"| DEFAULT
    DEFAULT -->|"--auto flag<br/>user opts in"| AUTO
    AUTO -->|"denial limit<br/>exceeded"| DEFAULT
    DEFAULT -->|"--dangerously-<br/>skip-permissions"| BYPASS

    classDef neutral fill:#333,stroke:#888,color:#e0e0e0,stroke-width:1px
    classDef mode1 fill:#1a2d4a,stroke:#4a9eff,color:#e0e0e0,stroke-width:2px
    classDef mode2 fill:#2d2d0d,stroke:#ffc107,color:#e0e0e0,stroke-width:2px
    classDef mode3 fill:#0d4f4f,stroke:#17a2b8,color:#e0e0e0,stroke-width:2px
    classDef mode4 fill:#4a1a1a,stroke:#dc3545,color:#e0e0e0,stroke-width:2px

Default Mode

  • Every write operation prompts the user
  • Read operations (FileRead, Glob, Grep) auto-approved
  • Most secure, most friction

Plan Mode

  • Entered via /plan command or model's EnterPlanMode tool
  • All read tools auto-approved
  • All write tools require explicit user approval
  • Model can plan freely, execute cautiously

Auto Mode

  • Enabled via --auto flag
  • Safety classifier decides per-tool
  • Falls back to prompting if classifier says "unsafe"
  • Has a denial limit — too many denials drops back to Default

Bypass Mode

  • Enabled via --dangerously-skip-permissions
  • Everything auto-approved — no checks at all
  • Named to be scary because it IS scary
  • No permission system protection whatsoever

The ToolPermissionContext Type

All permission state lives in AppState.toolPermissionContext:

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#dc3545', 'primaryBorderColor': '#dc3545'}}}%%
graph TB
    subgraph TPC["ToolPermissionContext — Immutable"]
        MODE["mode<br/>default / plan / auto / bypass"]
        AWD["additionalWorkingDirectories<br/>extra safe paths"]
        ALLOW["alwaysAllowRules<br/>by source: settings, command, etc."]
        DENY["alwaysDenyRules<br/>by source"]
        ASK["alwaysAskRules<br/>force prompt even if allowed"]
        BPM["isBypassPermissionsModeAvailable<br/>can user enable bypass?"]
        AUTO_A["isAutoModeAvailable<br/>can user enable auto?"]
        AVOID["shouldAvoidPermissionPrompts<br/>background agents that cannot show UI"]
        AWAIT["awaitAutomatedChecksBeforeDialog<br/>coordinator workers"]
        PRE["prePlanMode<br/>mode to restore after plan exits"]
    end

This is wrapped in DeepImmutable<T> — TypeScript enforces that nobody mutates this in place. Updates go through setAppState(prev => ({ ...prev, toolPermissionContext: { ... } })).


Denial Tracking

Auto mode tracks denials to prevent runaway unsafe operations:

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'lineColor': '#dc3545', 'primaryBorderColor': '#dc3545'}}}%%
flowchart LR
    START["Auto mode active"]:::start
    D1["Denial 1"]:::deny
    D2["Denial 2"]:::deny
    DN["Denial N<br/>limit exceeded"]:::deny
    FALLBACK["Fall back to<br/>Default mode"]:::result

    START --> D1 --> D2 -->|"..."| DN --> FALLBACK

    classDef start fill:#0d4f4f,stroke:#17a2b8,color:#e0e0e0,stroke-width:2px
    classDef deny fill:#3d2b00,stroke:#fd7e14,color:#e0e0e0,stroke-width:2px
    classDef result fill:#1a2d4a,stroke:#4a9eff,color:#e0e0e0,stroke-width:2px

This is stored in DenialTrackingState — for async subagents that can't show UI, a local tracking copy is used since their setAppState is a no-op.


Previous: ← Tool System · Next: Context Management →