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

340 lines
11 KiB
Markdown

# 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
```mermaid
%%{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:
```json
{
"alwaysDenyRules": {
"settings": [
{ "tool": "Bash", "pattern": "rm -rf" },
{ "tool": "FileWrite", "pattern": "/etc/*" }
]
}
}
```
### Permission Matching
Tools can implement `preparePermissionMatcher()` for custom pattern matching:
```typescript
// 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)
```mermaid
%%{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)`:
```typescript
// 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:
```json
{
"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:
```mermaid
%%{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
```mermaid
%%{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`:
```mermaid
%%{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:
```mermaid
%%{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](./03-tool-system.md) · **Next:** [Context Management →](./05-context-management.md)