Claude Code Permission System in 2026: Allow Lists Guide

Claude Code Permission System in 2026: Allow Lists Guide

Read(/) on a personal machine hands an AI agent a direct view of ~/.ssh, ~/.aws, browser cookie stores, and every credential file the current user can reach. The official documentation lists this as a valid allow pattern without flagging what that scope actually touches. What the docs underemphasize is that the permission system is layered across four resolution levels and four execution modes, so a single entry never means what it looks like in isolation. This post maps the evaluation stack, the sandboxing gaps that behave differently on macOS versus Linux CI containers, and the specific allow list discipline that keeps a session from reaching things you forgot you had.


Where the Rules Actually Live

Settings Resolution Order: Lowest to Highest Precedence

Settings Resolution Order: Lowest to Highest Precedence

LAYER 1 , LOWEST PRECEDENCE
User-Level Config
~/.claude/settings.json
LAYER 2
Project-Level Config
.claude/settings.json
LAYER 3
Project-Local Config
.claude/settings.local.json
LAYER 4
CLI Flags
Passed at runtime via command line
LAYER 5 , HIGHEST PRECEDENCE
Enterprise Managed Settings
System policy, overrides all layers
Key rule: deny beats allow at the same layer. A project-level deny can nullify a user-level allow.

Source: Claude Code documentation and article analysis, 2026

Source: Claude Code official documentation and article analysis, 2026


Permissions are configured in settings.json. As of mid 2026, Claude Code resolves this file from multiple locations in order: a user-level config (~/.claude/settings.json), a project-level .claude/settings.json, a project-local .claude/settings.local.json, CLI flags, and finally an enterprise managed settings file (highest precedence). Each layer can override or extend the one below it. That ordering matters more than any individual rule, because a project-level deny rule can nullify a user-level allow, and a system policy can lock out changes that look like they should work.


The four permission modes: default (interactive approval for anything outside the allow list), auto-approve (skips prompts for allowed tools), plan mode (read-only pass before execution), and a headless/non-interactive mode used in CI. Each one changes which rules get enforced and which get silently skipped. Running headless without a properly scoped allow list does not produce an error. The session just refuses tool calls without explanation, which looks identical to a model that has decided not to act.


Allow and deny entries follow a tool-plus-path pattern. A rule like Bash(git *) allows any git subcommand. A rule like Read(~/workspace/**) scopes reads to one directory tree. The syntax is documented, but the evaluation order within a single config file is not spelled out anywhere obvious: deny rules win over allow rules at the same layer. That means you can accidentally block something with a broad deny and then spend twenty minutes wondering why a narrowly scoped allow is being ignored.


{
  "permissions": {
    "allow": [
      "Read(~/workspace/**)",
      "Write(~/workspace/**)",
      "Bash(git *)",
      "Bash(npm run *)"
    ],
    "deny": [
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Bash(rm *)"
    ]
  }
}

That config is narrower than most tutorials suggest starting with, and that is precisely the point. The deny entries for SSH and AWS directories do not add security theater over a scoped Read allow. They act as a hard stop if the allow list ever gets broadened carelessly during a later edit session. Defense in depth inside a single config file is not redundant.


The project-level settings file is the one that actually travels with the codebase, which means it is also the one that gets committed to version control, reviewed in pull requests, and occasionally rewritten by someone who did not know what it was. Claude Code does not natively support comments in the settings file, but a companion CLAUDE.md entry explaining the permission rationale has prevented at least two confused rewrites on teams I have seen using this setup.


The settings resolution order is the most under-documented part of the whole system. The gap between what a single file looks like and what the merged effective config actually is causes more confusion than any individual rule syntax ever does. Until Anthropic publishes a clearer precedence table, testing the merged output with the print-config equivalent before any long session is the only reliable verification method available.


Sandboxing Limits and What Falls Outside Them

Claude Code: Four Permission Modes Compared

Claude Code: Four Permission Modes Compared

Mode Prompts User? Primary Use Case
Default Yes, for anything outside allow list Interactive development sessions
Auto-Approve No, skips prompts for allowed tools Faster iteration with trusted tool sets
Plan Mode Read-only pass before execution Review intended actions before running
Headless / CI No, silently refuses unknown tool calls Automated pipelines, no user present
Warning: Headless mode with an unscoped allow list produces no error. Silent tool call refusals look identical to the model choosing not to act.

Source: Claude Code official documentation, 2026

Source: Claude Code official documentation, 2026


Understanding where the allow list ends and platform enforcement begins requires knowing how the two layers interact. Claude Code's sandboxing operates at the process level, but the strength of that enforcement varies by platform. On macOS it uses the platform sandbox facilities (Seatbelt framework) to restrict what sandboxed commands can do. On Linux, bubblewrap provides namespace-based isolation. The distinction matters: a model running inside a properly sandboxed session on macOS that attempts to reach a path outside the allow list gets a hard block at the OS layer. The same session on a Linux environment without seccomp configured may rely entirely on the allow list logic in the application layer, which is a meaningfully different threat model.


What sandboxing does not cover is network access. Based on observed behavior, Claude Code does not enforce network-level restrictions through the sandbox by default, according to analysts and users who have examined this area. A Bash allow rule that permits curl * or wget * gives the model outbound HTTP from whatever network the host is on. Whether that matters depends on the environment, but it is the kind of thing that surprises people who assumed "sandbox" meant network isolation too.


# This allow entry looks narrow
"Bash(curl https://api.example.com/*)"

# But curl follows redirects by default.
# A redirect from api.example.com to an internal endpoint
# is not blocked by the path pattern in the allow rule.
# The rule matches the initial command string, not the resolved destination.

The redirect behavior is not a bug in the permission system. It is a consequence of the rule matching against the command string at invocation time, not against the network behavior that follows. Knowing this changes how you write Bash allow rules for any tool that does HTTP: you are scoping the command, not the conversation that command starts with the network.


Plan mode sits adjacent to sandboxing in the mental model most people bring to this, but it is doing something different. Plan mode restricts the session to read and analysis operations so the model can describe what it intends to do before any writes happen. It is not a sandbox in the OS sense. It is a mode constraint. Conflating the two leads to configurations where someone enables plan mode and then assumes write restrictions are enforced at a lower level than they actually are.


Sandboxing in Claude Code is real and useful on macOS, meaningfully thinner on Linux without extra configuration, and silent about the gap. If your CI runs on Linux containers, the effective security posture of a session depends more on allow list discipline than on any platform-level enforcement. That asymmetry between platforms is what the official documentation underemphasizes, and it is the part most likely to matter in a real incident.


Scoping Reads to Avoid the Dotfiles Problem

Example Config: Allow vs. Deny Rules by Tool Category

Example Config: Allow vs. Deny Rules by Tool Category

Each bar shows allow (blue) and deny (red) entries per tool category

2
2 Allow
Read
3
1 Deny
1 Allow
Write
4
1 Deny
2 Allow
Bash
Allow rules
Deny rules (win over allow at same layer)
Total: 4 allow entries, 3 deny entries. Explicit deny for ~/.ssh, ~/.aws, and rm provides defense in depth even with a scoped Read allow.

Source: Sample settings.json from article, 2026

Source: Sample settings.json from article, 2026


Because the allow list carries most of the security weight on Linux and fills gaps even on macOS, how you write path rules determines what the model can actually reach. Two allow patterns appear in more codebases than they should: Read(/) and Write(~/). The first is usually added when someone is tired of the model asking for read access to a dependency buried three directories deep. The second appears after someone has watched the model produce a clean edit and then fail to write it because the target path was outside the current allow scope. Both are understandable in the moment. Both create configurations that are hard to reason about later.


The dotfiles scenario is specific enough to be worth naming. A project directory that includes a symlink to ~/.zshrc or ~/.gitconfig, or a workspace root that is the home directory itself, combined with a broad Write allow, means the model can reach shell configuration files. Claude Code does not go looking for these. The issue is that the model responds to what is in context, and if a task description mentions shell setup or aliases, and the files are reachable, they become part of the working surface.


{
  "permissions": {
    "allow": [
      "Read(~/projects/myapp/**)",
      "Write(~/projects/myapp/src/**)",
      "Write(~/projects/myapp/tests/**)"
    ],
    "deny": [
      "Write(~/projects/myapp/.env)",
      "Write(~/projects/myapp/.env.*)",
      "Read(~/)"
    ]
  }
}

That last deny entry, Read(~/), is doing something specific: it blocks the model from reading the home directory root directly while the more specific Read allow still covers the project tree. Without it, a broad read on the project and a workspace root set to home gives the model a path to dotfiles through directory traversal. The deny at the home root level does not need to enumerate every sensitive path. It closes the traversal surface.


Scoping writes to src and tests explicitly, rather than to the whole project tree, is a choice with a cost: the model will prompt for approval when it wants to write a config file at the project root, a Makefile, or a README. That friction is intentional. The alternative is a write scope broad enough to include files that did not need to be in scope. For long-running sessions on codebases with mixed file types, the narrower scope produces a cleaner audit trail of what the model actually touched.


There is a subtler issue with environment files specifically. A deny rule on .env and .env.* covers the common naming patterns, but config/secrets.yaml, terraform.tfvars, and infrastructure-as-code files with embedded credentials do not match those patterns. The allow list is not a secrets scanner. It is a path filter. Anyone building a permission config for a project with multiple credential file conventions needs to enumerate them, or deny by directory rather than by file name. The dotfiles and broad-read problems are not edge cases that careless users hit. They are the natural endpoint of incrementally loosening a permission config under time pressure, and the settings file gives no feedback that the effective scope has grown past what anyone intended.


Auto-Approve Mode Versus Interactive Approval


With a correctly scoped allow list in place, the remaining decision is which mode governs what happens at the boundary that list defines. Auto-approve mode exists for workflows where the approval prompt is friction with no corresponding signal. A long-running refactor session where every tool call is already covered by a well-tested allow list is a reasonable candidate. Exploratory sessions on an unfamiliar codebase, or any session where the task description is still being refined, are not. The mode is not wrong. The question is whether the allow list was written for the task at hand or for some prior task that seemed similar.


Interactive approval, the default mode, surfaces tool calls that fall outside the allow list and asks before proceeding. The approval prompt includes the tool name and the argument. It does not include a diff preview for write operations in all contexts, which means approving a Write call tells you the file path but not always what is being written. For small files this is usually fine. For configuration files with nested structure, approving on the path name alone is the kind of shortcut that produces surprises later in the session.


# Checking what the current effective allow list looks like in a session.
# --print-config is an observed pattern in CLI usage;
# verify against current CLI docs as flag names evolve across releases.
claude --print-config 2>/dev/null | grep -A 20 '"permissions"'

The --print-config flag, or its equivalent in whatever CLI version is current, is underused. The merged effective config from all four layers is not the same as the project-level settings.json, and checking what the session is actually running with before a long auto-approve task is the kind of thirty-second verification that has prevented at least a few unexpected writes on machines I have seen in action.


There is a version skew problem that surfaces in team environments. The system-level policy file is typically managed by whoever set up the shared environment. The project-level config is managed by whoever last touched it in version control. When all three config layers exist and the team has not aligned on which layer owns which decisions, the effective config becomes difficult to audit without running the print-config equivalent on each machine individually. Standardizing on project-level as the source of truth for workspace scope, and reserving user-level for personal workflow preferences that do not affect security surface, is not enforced by the tool but it is the pattern that produces the least confusion over time.


Auto-approve mode and a well-scoped allow list are not the same thing. You can run auto-approve with a narrow allow list and still get approval prompts for out-of-scope calls. You can also run default interactive mode and never see a prompt if your allow list is broad enough to cover everything the model tries. The mode controls what happens at the boundary. The allow list determines where the boundary is. Treating them as interchangeable is how teams end up with a session that feels locked down and turns out to have a write scope covering the entire home directory. That is the same trap Read(/) sets on a personal machine: a single entry that looks contained, sitting above three layers of config the developer never checked, reaching credential files they forgot they had.