Articles
How Claude Code's Permission System Actually Works
A close look at Claude Code permission rules, hooks, and approval files, and why those local files are not the same thing as team policy.
When Claude Code asks "Allow this tool call?" and you click approve, a few things happen behind the prompt. There are static rules, plugin hooks, pattern matching, and local files that keep growing as you approve more work.
That matters because Claude Code is not just a chat window. It reads files, writes code, runs shell commands, makes network requests, spawns subagents, and manages tasks on your workstation. It runs with your credentials and your shell environment. The permission system is the main gate between what the model asks for and what your machine actually does.
The Three Permission Decisions
Every tool call lands in one of three buckets:
- Allow - run it now.
- Deny - block it and return an error to the model.
- Ask - stop and ask the user.
These decisions are evaluated from multiple sources, in priority order. A deny from any source overrides an allow from another. The default behavior when no rule matches depends on the permission mode.
Where Permission Rules Live
Claude Code reads permission rules from two locations, and merges them:
Global user settings at ~/.claude/settings.json:
{
"permissions": {
"allow": [
"Bash(cargo check)",
"Bash(cargo build --release)",
"Bash(cargo run:*)",
"Bash(cargo build:*)"
],
"deny": [],
"ask": [],
"defaultMode": "default"
}
}
Project-level settings at .claude/settings.local.json in the working directory:
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"Bash(npx tsc:*)",
"Bash(bun add:*)",
"Read(/mnt/disks/data-disk/code/rye/web/**)",
"WebFetch(domain:github.com)"
]
}
}
Project settings override globals for the same patterns. Both files use the same rule format.
Rule Pattern Syntax
Permission rules follow the pattern ToolName(pattern), where the pattern depends on the tool:
Bash(cargo check) # Exact command match
Bash(cargo:*) # Prefix match: any cargo subcommand
Bash(npm run:*) # Prefix: npm run with any arguments
Read(/repo/**) # Recursive glob: any file under /repo
WebFetch(domain:github.com) # Domain restriction for web fetches
The * wildcard at the end of a Bash pattern matches any remaining arguments. The ** glob in Read and Write patterns matches recursively. Domain-scoped WebFetch rules restrict which hosts the assistant can reach.
When you approve a tool call interactively, Claude Code may offer to "always allow" it. If you accept, a new rule is appended to settings.local.json. Over time, that file grows to reflect everything you have ever approved in that project.
The Hook System
Beyond static rules, Claude Code supports a hook system through plugins. Hooks are shell commands that fire on specific events in the tool-call lifecycle:
- PreToolUse - fires before a tool call executes. Can allow, deny, or force an ask.
- PostToolUse - fires after a tool call completes. Can log, alert, or modify the result.
- UserPromptSubmit - fires when the user submits a prompt. Can validate or transform input.
- Stop - fires when the session is about to end. Can prevent premature termination.
A PreToolUse hook receives the tool name and input as JSON on stdin:
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build-artifacts",
"timeout": 120000
},
"hook_event_name": "PreToolUse"
}
The hook can return a permission decision:
{
"hookSpecificOutput": {
"permissionDecision": "deny"
},
"systemMessage": "Recursive deletion blocked by policy."
}
Hooks are registered in plugin configuration files and fire as child processes. The timeout is typically short (10 seconds). If a hook errors or times out, the system fails open - the tool call proceeds as if the hook did not exist.
Hookify: Rules as Markdown
The official hookify plugin extends this with a rule engine that uses Markdown files with YAML frontmatter:
---
name: block-dangerous-rm
enabled: true
event: bash
action: block
pattern: rm\s+-rf
---
Dangerous recursive deletion detected.
More complex rules support multiple conditions:
---
name: block-env-secrets
enabled: true
event: file
action: block
conditions:
- field: file_path
operator: regex_match
pattern: \.env$|credentials
- field: new_text
operator: contains
pattern: API_KEY
---
These rules are stored as .claude/hookify.*.local.md files in the project directory. They support operators like regex_match, contains, equals, starts_with, ends_with, and not_contains, applied to fields like command, file_path, new_text, old_text, and user_prompt.
This is useful local plumbing. It is also only local. There is no central list of hook rules across a team, no built-in review trail for rule changes, and no easy way to prove that a rule was active during a session.
How Approvals Accumulate
The risk shows up slowly.
Every time a developer approves a tool call with "always allow," a new pattern is added to the project's permission file. Over weeks of use, that file grows organically:
{
"permissions": {
"allow": [
"Bash(cargo:*)",
"Bash(sqlite3:*)",
"Bash(npm run:*)",
"Bash(npx tsc:*)",
"Bash(bun add:*)",
"Bash(wc:*)",
"Bash(xargs ls:*)",
"Bash(xargs cat:*)",
"Read(/mnt/disks/data-disk/code/rye/web/**)",
"WebFetch(domain:github.com)",
"WebFetch(domain:claude.ai)"
]
}
}
Each approval may have made sense in the moment. Taken together, they become a widening allowlist that almost nobody reviews.
The Codex Comparison
Codex CLI has the same pattern, expressed differently. Its ~/.codex/rules/default.rules file persists prefix-match approval rules:
prefix_rule(pattern=["rg"], decision="allow")
prefix_rule(pattern=["sed"], decision="allow")
prefix_rule(pattern=["cargo", "run", "--bin", "rye-worker"], decision="allow")
prefix_rule(pattern=["sqlite3"], decision="allow")
prefix_rule(pattern=["find"], decision="allow")
prefix_rule(pattern=["git", "add"], decision="allow")
prefix_rule(pattern=["git", "commit"], decision="allow")
prefix_rule(pattern=["docker", "build"], decision="allow")
prefix_rule(pattern=["docker", "run"], decision="allow")
In practice, this file can get much messier. A single approval for a database query can persist as a multi-line rule with connection strings, SQL, and environment references. Here is one real-world example, shortened:
prefix_rule(pattern=["/usr/bin/zsh", "-lc",
"set -a; source .env; set +a; psql \"$DATABASE_URL\" -X
-v ON_ERROR_STOP=1 -c \"SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'rye_proxy'...\""],
decision="allow")
One click created that rule. It is now part of the allowed command set for future sessions. It references environment files and database URLs. The only record is a local flat file.
What the Permission System Does Not Do
Claude Code's permission system does a good job for one developer trying to move quickly. It reduces prompts for routine work and still catches risky actions. It does not solve these team problems:
No central policy distribution. Permission rules exist per-user and per-project on each machine. There is no mechanism for an organization to push a policy that says "deny all docker run commands" or "require approval for any Bash call that references .env files" across every developer's Claude Code installation.
No approval attribution. When a rule appears in settings.local.json, there is no record of who approved it, when, or in response to what tool call. The file only contains the current rule set.
No expiration. Once a rule is added, it stays until someone manually edits the file. There are no time-bound approvals, no periodic review cycles, and no automatic narrowing of permissions.
No cross-tool normalization. Claude Code uses Bash(cargo:*) patterns. Codex uses prefix_rule(pattern=["cargo"], decision="allow"). Cursor has its own format. A team using multiple assistants has three separate, incompatible permission systems to reason about.
No independent verification. The permission system records what it decided to allow. It does not independently verify what the tool call actually did. If Bash(npm run:*) is allowed and npm run build calls a postinstall script that exfiltrates environment variables, the permission system has no visibility into that.
Fail-open hooks. If a PreToolUse hook times out or crashes, the tool call proceeds. That is nice for developer flow. It also means hook-based policy is advisory unless something outside the assistant enforces it.
The --dangerously-skip-permissions Flag
Claude Code supports a --dangerously-skip-permissions flag that disables the permission system entirely. The name is intentionally alarming, and it is documented as being for CI/CD and automated workflows where interactive approval is not possible.
When this flag is active, all tool calls - file reads, writes, shell commands, web fetches - execute without any permission check. The only remaining guardrails are the model's own instructions and whatever sandboxing the host environment provides.
The flag exists for a reason: CI jobs, batch refactors, and other headless runs cannot click prompts. But the tradeoff is too blunt. You get interactive permissions, or you get no permissions. There is no built-in middle ground that says: enforce this central policy without asking a human each time.
What This Means for Teams
For a solo developer, Claude Code's permission system is more than adequate. It asks before doing anything risky, it remembers your preferences, and it gets out of your way for routine operations.
The challenges emerge at team scale:
--dangerously-skip-permissions) bypasses all controls, and there is no intermediate option for policy-only enforcement.The permission system is useful. Teams still need the layer above it: central policy distribution, approval auditing, cross-tool normalization, independent checks on what actually happened, and enforcement that still works in headless runs.
That layer should not depend on one assistant's codebase. It has to sit around the assistant and watch the process, the network traffic, and the filesystem changes the permission file never sees.