Skip to content

Rule System

Claude Code’s permission system is driven by rules — declarative statements that say “allow this tool”, “deny that command”, or “always ask before running this”. Rules can come from 8 different sources, each with different persistence characteristics and trust levels. When rules conflict, a deterministic priority system resolves the ambiguity.

Every permission rule has three components:

src/types/permissions.ts
export type PermissionRule = {
source: PermissionRuleSource
ruleBehavior: PermissionBehavior // 'allow' | 'deny' | 'ask'
ruleValue: PermissionRuleValue
}
export type PermissionRuleValue = {
toolName: string // "Bash", "Edit", "mcp__server__tool"
ruleContent?: string // Optional: "npm install:*", "git commit"
}

Rules are persisted as strings in settings files and parsed by permissionRuleParser.ts:

src/utils/permissions/permissionRuleParser.ts
export function permissionRuleValueFromString(ruleString: string): PermissionRuleValue {
// Find the first unescaped opening parenthesis
const openParenIndex = findFirstUnescapedChar(ruleString, '(')
if (openParenIndex === -1) {
return { toolName: normalizeLegacyToolName(ruleString) }
}
// Extract tool name and content
const toolName = ruleString.substring(0, openParenIndex)
const rawContent = ruleString.substring(openParenIndex + 1, closeParenIndex)
// Empty content or standalone wildcard → tool-wide rule
if (rawContent === '' || rawContent === '*') {
return { toolName: normalizeLegacyToolName(toolName) }
}
return { toolName, ruleContent: unescapeRuleContent(rawContent) }
}

Parentheses in rule content are escaped with backslashes:

// Bash(python -c "print(1)") is stored as:
// Bash(python -c "print\(1\)")
export function escapeRuleContent(content: string): string {
return content
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/\(/g, '\\(') // Escape opening parentheses
.replace(/\)/g, '\\)') // Escape closing parentheses
}

Rules match against commands in three modes, implemented via ShellPermissionRule:

ModeSyntaxExampleMatches
ExactcommandBash(npm install express)Only npm install express
Prefixcommand:*Bash(npm install:*)npm install express, npm install -D jest, etc.
Wildcardpattern*patternBash(*test*)Any command containing “test”
src/utils/permissions/shellRuleMatching.ts
export function parsePermissionRule(rule: string): ShellPermissionRule {
// Check for :* suffix → prefix rule
// Check for * → wildcard rule
// Otherwise → exact match
}

Rules originate from 8 sources, defined across two type systems:

src/utils/settings/constants.ts
export const SETTING_SOURCES = [
'userSettings', // 1. User settings (global)
'projectSettings', // 2. Project settings (shared)
'localSettings', // 3. Local settings (gitignored)
'flagSettings', // 4. Flag settings (--settings flag)
'policySettings', // 5. Policy settings (managed/enterprise)
] as const
// src/types/permissions.ts — additional rule-specific sources
export type PermissionRuleSource =
| SettingSource // All 5 above
| 'cliArg' // 6. CLI arguments
| 'command' // 7. In-session commands
| 'session' // 8. User approvals during session

Location: ~/.claude/settings.json Scope: Global — applies to all projects Persistence: Permanent until manually edited Display name: “user settings”

{
"permissions": {
"allow": ["Edit", "Bash(git:*)"],
"deny": ["Bash(rm -rf:*)"],
"ask": []
}
}

Source 2: Project Settings (projectSettings)

Section titled “Source 2: Project Settings (projectSettings)”

Location: .claude/settings.json in project root Scope: This project — shared across team Persistence: Committed to version control Display name: “shared project settings”

Teams use this to define baseline rules for the project:

{
"permissions": {
"allow": ["Bash(npm test:*)", "Bash(npm run lint:*)"],
"deny": ["Bash(npm publish:*)"]
}
}

Location: .claude/settings.local.json in project root Scope: This project, this machine only Persistence: Gitignored — not shared Display name: “project local settings”

This is where user approvals from “always allow” prompts are typically saved:

{
"permissions": {
"allow": ["Bash(python3:*)", "Bash(cargo build:*)"]
}
}

Location: File path specified by --settings CLI flag Scope: This session Persistence: As long as the file exists Display name: “command line arguments”

Terminal window
claude --settings /path/to/custom-settings.json

Source 5: Policy Settings (policySettings)

Section titled “Source 5: Policy Settings (policySettings)”

Location: managed-settings.json or remote API Scope: Enterprise-wide Persistence: Managed by IT Display name: “enterprise managed settings”

When allowManagedPermissionRulesOnly is enabled, this source overrides all others:

src/utils/permissions/permissionsLoader.ts
export function loadAllPermissionRulesFromDisk(): PermissionRule[] {
if (shouldAllowManagedPermissionRulesOnly()) {
return getPermissionRulesForSource('policySettings')
}
// Otherwise, load from all enabled sources
}

Scope: This session Persistence: Command-line only

Terminal window
claude --allowed-tools "Bash(git:*),Edit"
claude --denied-tools "Bash(curl:*)"

Scope: This session Persistence: Memory only

> /allowed-tools Bash(npm test:*)

Scope: This session Persistence: Memory only (lost on exit)

When a user approves a permission prompt with “Yes, allow this once”, the rule is stored in the session source.

When the same command matches rules with different behaviors, the priority is always:

  1. Deny — checked first, takes immediate effect
  2. Ask — checked second, triggers permission prompt
  3. Allow — checked last, auto-approves

This is enforced throughout the codebase:

// bashToolCheckPermission (src/tools/BashTool/bashPermissions.ts)
// 1. Exact match first (deny > ask > allow)
// 2. Prefix/wildcard deny → DENY
// 3. Prefix/wildcard ask → ASK
// 4. Path constraints
// 5. Prefix/wildcard allow → ALLOW

Within the same behavior (e.g., two allow rules from different sources), the first match wins. Rules are collected from all sources in order:

src/utils/permissions/permissions.ts
const PERMISSION_RULE_SOURCES = [
...SETTING_SOURCES, // userSettings, projectSettings, localSettings, flagSettings, policySettings
'cliArg',
'command',
'session',
] as const satisfies readonly PermissionRuleSource[]

The SETTING_SOURCES order is defined in constants.ts:

// src/utils/settings/constants.ts (line 7)
export const SETTING_SOURCES = [
'userSettings', // First (lowest priority for settings, but rules use first-match)
'projectSettings',
'localSettings',
'flagSettings',
'policySettings', // Last (highest trust)
] as const
flowchart TD
A[Tool invocation] --> B[Collect ALL rules from ALL sources]
B --> DenyRules["Deny Rules\n(all sources merged)"]
B --> AskRules["Ask Rules\n(all sources merged)"]
B --> AllowRules["Allow Rules\n(all sources merged)"]
DenyRules --> D1{Any deny\nmatches?}
D1 -->|Yes| DENY[DENY]
D1 -->|No| A1
AskRules --> A1{Any ask\nmatches?}
A1 -->|Yes| ASK[ASK]
A1 -->|No| L1
AllowRules --> L1{Any allow\nmatches?}
L1 -->|Yes| ALLOW[ALLOW]
L1 -->|No| PASSTHROUGH[No rule matched\nfallback to mode]

Some permission checks cannot be overridden by any rule or mode. These are the system’s ultimate safety net:

Files in .git/, .claude/, .vscode/, and shell configuration files (.bashrc, .zshrc, .profile) always trigger a permission prompt, even in bypassPermissions mode:

// src/utils/permissions/permissions.ts (step 1g)
if (toolPermissionResult?.behavior === 'ask' &&
toolPermissionResult.decisionReason?.type === 'safetyCheck') {
return toolPermissionResult // Cannot be bypassed
}

When a user explicitly configures an ask rule with content (e.g., Bash(npm publish:*)), this survives even in bypass mode:

// Step 1f: Content-specific ask rules
if (toolPermissionResult?.behavior === 'ask' &&
toolPermissionResult.decisionReason?.type === 'rule' &&
toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask') {
return toolPermissionResult // Respected even in bypass mode
}

3. Dangerous Pattern Stripping at Auto Mode Entry

Section titled “3. Dangerous Pattern Stripping at Auto Mode Entry”

When transitioning to auto mode, dangerous allow rules are proactively removed:

src/utils/permissions/permissionSetup.ts
export function isDangerousBashPermission(toolName: string, ruleContent?: string): boolean {
// Bash (no content) = allow all → DANGEROUS
// Bash(python:*) = allow interpreter → DANGEROUS
// Bash(git commit:*) = specific command → SAFE
}

Dangerous patterns include:

  • Tool-level allow (Bash with no content)
  • Interpreter prefixes: python:*, node:*, ruby:*, perl:*, etc.
  • Eval-equivalents: eval:*, exec:*, env:*, xargs:*
  • Shell launchers: bash:*, sh:*, zsh:*
  • Remote execution: ssh:*
  • Privilege escalation: sudo:*

Rules are stored in the permissions key of settings JSON files:

{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Edit",
"Bash(git:*)",
"Bash(npm test:*)",
"Bash(npm run lint:*)",
"mcp__server1"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(npm publish:*)",
"Bash(curl:*)"
],
"ask": [
"Bash(docker:*)"
]
}
}

MCP (Model Context Protocol) tools support server-level rules:

// src/utils/permissions/permissions.ts (line ~259)
// rule "mcp__server1" matches tool "mcp__server1__tool1"
const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
const toolInfo = mcpInfoFromString(nameForRuleMatch)
return ruleInfo !== null && toolInfo !== null &&
(ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
ruleInfo.serverName === toolInfo.serverName
RuleMatches
mcp__server1All tools from server1
mcp__server1__*All tools from server1 (wildcard variant)
mcp__server1__readOnly the read tool from server1

Tool names are normalized for backward compatibility:

src/utils/permissions/permissionRuleParser.ts
const LEGACY_TOOL_NAME_ALIASES: Record<string, string> = {
Task: AGENT_TOOL_NAME, // "Task" → "Agent"
KillShell: TASK_STOP_TOOL_NAME, // "KillShell" → "TaskStop"
AgentOutputTool: TASK_OUTPUT_TOOL_NAME,
BashOutputTool: TASK_OUTPUT_TOOL_NAME,
}

When rules change (user approval, settings edit, CLI command), updates flow through a typed operation system:

src/types/permissions.ts
export type PermissionUpdate =
| { type: 'addRules'; destination: PermissionUpdateDestination; rules: PermissionRuleValue[]; behavior: PermissionBehavior }
| { type: 'replaceRules'; destination: PermissionUpdateDestination; rules: PermissionRuleValue[]; behavior: PermissionBehavior }
| { type: 'removeRules'; destination: PermissionUpdateDestination; rules: PermissionRuleValue[]; behavior: PermissionBehavior }
| { type: 'setMode'; destination: PermissionUpdateDestination; mode: ExternalPermissionMode }
| { type: 'addDirectories'; destination: PermissionUpdateDestination; directories: string[] }
| { type: 'removeDirectories'; destination: PermissionUpdateDestination; directories: string[] }

When settings files change on disk (hot reload), the system performs a full replacement sync:

src/utils/permissions/permissions.ts
export function syncPermissionRulesFromDisk(
toolPermissionContext: ToolPermissionContext,
rules: PermissionRule[],
): ToolPermissionContext {
let context = toolPermissionContext
// When allowManagedPermissionRulesOnly, clear all non-policy sources
if (shouldAllowManagedPermissionRulesOnly()) {
for (const source of sourcesToClear) {
for (const behavior of behaviors) {
context = applyPermissionUpdate(context, {
type: 'replaceRules', rules: [], behavior, destination: source,
})
}
}
}
// Clear ALL disk-based sources before applying new rules
// This ensures deleted rules are actually removed
for (const diskSource of ['userSettings', 'projectSettings', 'localSettings']) {
for (const behavior of ['allow', 'deny', 'ask']) {
context = applyPermissionUpdate(context, {
type: 'replaceRules', rules: [], behavior, destination: diskSource,
})
}
}
// Apply new rules
const updates = convertRulesToUpdates(rules, 'replaceRules')
return applyPermissionUpdates(context, updates)
}

Rules can be deleted from editable sources (user, project, local) but not from read-only sources (policy, flag):

export async function deletePermissionRule({ rule, ... }): Promise<void> {
if (rule.source === 'policySettings' || rule.source === 'flagSettings' || rule.source === 'command') {
throw new Error('Cannot delete permission rules from read-only settings')
}
// Update in-memory context
const updatedContext = applyPermissionUpdate(initialContext, {
type: 'removeRules', rules: [rule.ruleValue], behavior: rule.ruleBehavior,
destination: rule.source as PermissionUpdateDestination,
})
// Persist to disk for file-based sources
switch (destination) {
case 'localSettings':
case 'userSettings':
case 'projectSettings':
deletePermissionRuleFromSettings(rule)
break
case 'cliArg':
case 'session':
break // In-memory only, no disk persistence
}
}
{
"permissions": {
"allow": [
"Edit",
"Bash(git:*)",
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(npx:*)"
],
"deny": [
"Bash(npm publish:*)",
"Bash(rm -rf:*)"
]
}
}
{
"allowManagedPermissionRulesOnly": true,
"permissions": {
"allow": [
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"deny": [
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(ssh:*)"
]
}
}

In .claude/settings.local.json (gitignored):

{
"permissions": {
"allow": [
"Bash(docker compose:*)",
"Bash(python3:*)"
]
}
}

The --setting-sources flag controls which sources are loaded:

src/utils/settings/constants.ts
export function parseSettingSourcesFlag(flag: string): SettingSource[] {
// "user,project,local" → ['userSettings', 'projectSettings', 'localSettings']
}
export function getEnabledSettingSources(): SettingSource[] {
const allowed = getAllowedSettingSources()
// Always include policy and flag settings
const result = new Set<SettingSource>(allowed)
result.add('policySettings')
result.add('flagSettings')
return Array.from(result)
}

Policy and flag settings are always loaded, regardless of the --setting-sources flag. This ensures enterprise policies cannot be bypassed by local configuration.

The rule system achieves its security goals through:

  1. Behavior priority (deny > ask > allow) ensures the most restrictive rule always wins
  2. Source diversity (8 sources) supports individual, team, and enterprise workflows
  3. Non-bypassable checks protect critical system paths even in full-trust modes
  4. Dangerous pattern detection prevents overly broad rules from undermining auto mode
  5. Hot reload keeps rules current without restarting the session
  6. Legacy compatibility normalizes old tool names seamlessly