12.3 Permission Rule System
Permission rules are the foundational data structure of the entire permission system. Understanding rule format, matching methods, and priority is a prerequisite for understanding all subsequent security mechanisms.
Rule Format
Each rule consists of two parts: a tool name and an optional content matching pattern.
ToolName → Matches all calls to that tool
ToolName(content) → Matches calls to that tool with specific contentFor the Bash tool, content is the command string. For example:
| Rule | Meaning |
|---|---|
Bash | Matches all Bash commands |
Bash(npm install) | Exact match for npm install |
Bash(npm:*) | Prefix match — matches npm, npm install, npm run build, etc. |
Bash(git *) | Wildcard match — matches git commit, git push, etc. |
Edit | Matches all file edit operations |
Edit(src/**) | Matches file edits under the src directory |
For MCP tools, rules support server-level matching: mcp__server1 matches all tools on that server, mcp__server1__tool1 matches a specific tool.
Source:
src/utils/permissions/permissionRuleParser.ts,src/utils/permissions/shellRuleMatching.ts
Three Matching Types
The rule parser (parsePermissionRule) parses rule content into one of three types:
Exact Match: The rule content contains no :* suffix and no unescaped *. The command must be exactly identical to the rule content to match. For example, npm install only matches npm install, not npm install lodash.
Prefix Match (legacy :* syntax): The rule ends with :*. After stripping :*, the command matches if it starts with that prefix. For example, npm:* matches npm, npm install, npm run build. Note that npm:* also matches bare npm (no arguments) — this is intentional — allowing a prefix means trusting all uses of that command.
Wildcard Match: The rule contains an unescaped *. * is converted to regex .*, matching any character sequence. For example, git * --no-verify matches git commit --no-verify, git push --no-verify.
A subtle detail: when a pattern ends with * (space + wildcard) and there is only one wildcard in the entire pattern, the trailing part becomes optional — git * matches both git commit and bare git. This keeps the behavior of wildcard syntax consistent with prefix syntax.
// Simplified source code illustration
if (regexPattern.endsWith(' .*') && unescapedStarCount === 1) {
regexPattern = regexPattern.slice(0, -3) + '( .*)?'
}If you need to match a literal * (e.g., the command actually contains an asterisk), escape it with \*.
Source:
src/utils/permissions/shellRuleMatching.ts
Three Rule Behaviors
Each rule is associated with one behavior:
allow: Matched operations are auto-approved without user confirmationdeny: Matched operations are directly denied; the user cannot override (unless the rule is removed)ask: Matched operations force a confirmation dialog to appear, even in bypassPermissions mode
The existence of ask rules is an important security design: even if you use bypass mode for most operations, you can set ask rules for specific high-risk operations (such as npm publish, git push --force) as a safety valve.
Rule Sources and Priority
Rules can come from multiple sources. The source iterates over all of them in a fixed-order array, PERMISSION_RULE_SOURCES, collecting rules in this order (this is the iteration order, not a linear high-to-low priority):
| Iteration order | Source | Description | Storage Location |
|---|---|---|---|
| 1 | userSettings | User global settings | ~/.claude/settings.json |
| 2 | projectSettings | Project-level settings | .claude/settings.json (committed to repo) |
| 3 | localSettings | Local project settings | .claude/settings.local.json (not committed) |
| 4 | flagSettings | CLI startup parameters | Command-line --allowedTools, etc. |
| 5 | policySettings | Enterprise management policy | Delivered via enterprise MDM |
| 6 | cliArg | Runtime parameters | Passed in via API/SDK |
| 7 | command | Command-level rules | Custom command definitions |
| 8 | session | Session-level rules | Generated when user clicks "always allow" in dialog |
One common misconception worth clearing up: permission rules are additive — rules from all sources are collected and then adjudicated globally by behavior, in the order deny > ask > allow, rather than a simple "higher source overrides lower source". The table order mainly determines which source .find() returns as the first match (used for display/reference) and the delete/override behavior — not who wins over whom. So do not read userSettings (array position 1) as overriding policySettings (array position 5).
policySettings is the most authoritative not because it sits first in the source array, but because of three mechanisms: policy rules cannot be deleted (deletePermissionRule throws "Cannot delete permission rules from read-only settings" for policySettings), allowManagedPermissionRulesOnly can clear the rules of all non-policy sources, and deny globally takes precedence over allow. This is how administrators deliver mandatory rules via MDM that users cannot override.
Source:
src/utils/permissions/permissions.ts,src/utils/permissions/permissionsLoader.ts
Practical Configuration Example
// ~/.claude/settings.json
{
"permissions": {
"allow": [
"Bash(npm test:*)", // Allow all npm test commands
"Bash(git status)", // Allow git status
"Bash(git diff:*)", // Allow all git diff commands
"Read", // Allow all file reads
"Glob", // Allow all file searches
"mcp__filesystem" // Allow all tools from the filesystem MCP server
],
"deny": [
"Bash(rm -rf:*)", // Deny all rm -rf commands
"Bash(git push --force:*)" // Deny force push
],
"ask": [
"Bash(npm publish:*)", // Must confirm when publishing packages
"Bash(git push:*)" // Must confirm when pushing
]
}
}When the model calls Bash(npm test --coverage), the system matches the allow rule Bash(npm test:*) and auto-approves; when it calls Bash(npm publish), it matches the ask rule, and a confirmation dialog pops up even in bypassPermissions mode.