Articles

How Claude Code's Permission System Actually Works

A technical walkthrough of allow/deny/ask rules, PreToolUse hooks, approval accumulation, and why local permission files are not organizational policy.

When Claude Code asks "Allow this tool call?" and you click approve, what actually happens? The answer involves more machinery than most developers realize: a layered permission system, a plugin-based hook architecture, pattern-matching rule evaluation, and a local state directory that quietly accumulates your approval history into a de facto policy.

Understanding this system matters because Claude Code is not just a chat interface. It is a local agent runtime that reads files, writes code, runs shell commands, makes network requests, spawns subagents, and manages tasks — all on your workstation, with your credentials, in your shell environment. The permission system is the primary control surface between what the model asks for and what actually executes.

The Three Permission Decisions

Every tool call in Claude Code goes through a permission check that produces one of three outcomes:

  • Allow — the tool call executes immediately, no user interaction.
  • Deny — the tool call is blocked. The model receives an error and can try a different approach.
  • Ask — the user is prompted to approve or reject the specific tool call.

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 a powerful local extensibility mechanism. It is also entirely local. There is no central registry of which hook rules exist across a team's machines, no version control of rule changes, and no way to verify that a specific rule was active during a specific session.

How Approvals Accumulate

Here is where the system gets interesting — and where a subtle risk emerges.

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 individual approval was reasonable in context. But the aggregate effect is an expanding authorization surface that nobody reviews holistically.

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 also accumulates far more complex entries. A single "allow this time" click on a database query can persist as a multi-line rule containing connection strings, SQL statements, and environment variable references. Here is one real-world example (abbreviated):

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")

That rule was created from a single interactive approval. It is now permanently part of the agent's allowed command set for all future sessions. It references environment files and database URLs. And it exists only as a local flat file with no audit trail for when it was created or why.

What the Permission System Does Not Do

Claude Code's permission system is well-designed for its purpose: reducing friction for individual developers while providing basic guardrails. But there are specific things it does not address:

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. This is a reasonable default for developer experience — but it means hook-based policy is advisory, not enforceable.

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.

This flag exists because there is a real need: headless Claude Code sessions in CI pipelines, batch processing, or automated refactoring workflows. But it also highlights a gap. The choice today is binary: either full interactive permissions or no permissions at all. There is no mode that says "enforce this specific policy non-interactively" — a centrally managed, non-bypassable policy that works in both interactive and headless contexts.

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:

Permission files are committed to repos or left in home directories with no review process.
Different team members accumulate different approval sets for the same project, creating inconsistent security postures.
Hook-based rules are powerful but local — there is no way to verify they are deployed uniformly.
The gap between "what was allowed" and "what actually happened" requires correlating permission decisions with file system changes, network traffic, and shell output.
Headless usage (--dangerously-skip-permissions) bypasses all controls, and there is no intermediate option for policy-only enforcement.

The permission system is the right foundation. What is missing is the governance layer on top: central policy distribution, approval auditing, cross-tool normalization, independent behavioral verification, and non-bypassable enforcement for headless sessions.

That layer cannot live inside any single assistant's codebase — it needs to wrap the assistant from the outside, observing the process, the network traffic, and the filesystem changes that the permission system alone cannot see.