Mert Özen Further With Every Line
Artificial Intelligence Claude Code — #03

Permission Modes, Settings and Sandbox: Running Claude Code Under Control

Mert Özen Nov 15, 2026 12 min 4 views
Permission Modes, Settings and Sandbox: Running Claude Code Under Control

What Claude Code is allowed to do is decided by permission rules that Claude Code itself enforces, not by CLAUDE.md: a deny wins at every level and in every mode, but a Bash rule matches command text, not the program. The six permission modes, the Auto mode classifier, settings.json layers and precedence, allow/ask/deny syntax, Bookstore's team settings and the sandbox that native Windows lacks.

Version note: This post describes Claude Code 2.1.325 (13 November 2026); permission modes, rule syntax, settings layers and sandbox behaviour were checked on 15 November 2026 against the Permission modes, Configure permissions, Settings files and precedence and Sandboxing pages at code.claude.com/docs. This post is based on the official documentation. The sample settings files were parsed and validated locally as JSON.

Guess First

Emre, who works on Bookstore, the online bookstore used as the shared example across this site's series, got tired of Claude Code asking for approval on every command. He added "Bash(*)" to the team's committed .claude/settings.json and, to protect the connection details, wrote a Read(**/appsettings.Production.json) deny rule. Then he switched the session to Auto mode and relaxed: "Now everything runs without asking, and the secrets are safe."

What is your guess? In Auto mode, does the Bash(*) rule still run every command without approval? And if Claude runs grep -r ConnectionStrings . or dotnet user-secrets list, does that deny rule stop the connection details from entering the context? The first answer is in the Auto mode section, the second in the rule-writing section.

In the previous post we saw that CLAUDE.md shapes what Claude tries to do but doesn't decide what it is allowed to do. The docs say it in one sentence: permission rules are enforced by Claude Code, not by the model. This post covers that layer. The tool-independent side of secret and prompt injection risks with AI tools is in the fifth post of the AI-Assisted Development series, and the in-depth security story is in the LLM Application Security series.

Six Permission Modes

The permission mode decides which actions Claude takes without asking you. The six modes in the docs:

Mode (setting value)Runs without askingWhen
Manual (default)Reads onlySensitive work, when you want to see every step
Accept edits (acceptEdits)Reads, file edits, file commands such as mkdir, touch, rm, mv, cp, sedMoving fast on code you are reviewing
Plan (plan)Reads and exploration commands; does not edit sourceExploring and planning before changing (post four)
Auto (auto)Everything, checked by a separate classifier modelLong tasks, reducing approval fatigue
Don't ask (dontAsk)Only pre-approved tools; anything that would ask is deniedCI and scripts (post ten)
Bypass permissions (bypassPermissions)EverythingIsolated containers or VMs only

In an interactive session Shift+Tab cycles through the modes: from Auto to Manual, then Accept edits and Plan. Bypass joins the cycle only if it was enabled at launch, and Don't ask never does; you select it only with --permission-mode dontAsk. A label in the status bar such as ⏸ manual mode on or ⏵⏵ auto mode on shows the active mode.

The mode a session starts in comes from three sources in order: the --permission-mode flag, permissions.defaultMode in the settings files, then the built-in default. Since 2.1.283 the built-in default for interactive sessions in the terminal and VS Code is Auto. Two exceptions matter: claude -p and Agent SDK runs start in Manual, so calls that need approval are denied; and an auto or bypassPermissions value written into project settings files (.claude/settings.json or .claude/settings.local.json) doesn't take effect. A repo being unable to put you into Bypass mode on its own is a deliberate design.

Two rules hold in every mode. Deny rules block in every mode, Bypass included. And writing to protected paths such as .git, .vscode, .claude, .mcp.json, .bashrc and .npmrc turns into a prompt in Manual and Accept edits, goes to the classifier in Auto and is denied in Don't ask; only Bypass writes without asking. rm and rmdir commands that remove critical paths such as the root, your home folder or the working directory are never auto-approved by any allow rule.

How Auto Mode Decides

In Auto mode every action goes through a separate classifier model before it runs. The default block list in the docs includes: running downloaded code (such as curl | bash), sending sensitive data to an external endpoint, production deploys and migrations, force pushes, commands that discard uncommitted changes such as git reset --hard and git clean -fd, infrastructure teardown such as terraform destroy, granting IAM or repo permissions, and merging a pull request nobody has approved. Allowed by default are file operations in the working directory, installing dependencies declared in a lock file or manifest, read-only HTTP requests and pushing to branches of the repo you are working in, except branches named like deploy targets such as production.

When the classifier blocks an action you get a notification and the action lands in the Recently blocked tab of /permissions; if you approve it there, Claude can retry. After 3 blocks in a row or 20 in total, Auto mode pauses and Claude Code starts asking you again. Auto mode is available on all plans; on Team and Enterprise it is on by default and an admin can turn it off by setting permissions.disableAutoMode to "disable" in managed settings. On the Anthropic API it needs Opus 4.6 or later, Sonnet 4.6 or later, Haiku 5.5 or a Fable model. The docs' warning is clear: Auto mode reduces prompts, it doesn't guarantee safety.

Here is the answer to the first question. When entering Auto mode, Claude Code switches off broad allow rules: rules that cover everything such as Bash(*) and PowerShell(*), interpreter rules with wildcards such as Bash(python*), package manager run commands and allow rules for subagents and the Monitor tool. Narrow rules such as Bash(npm test) stay in effect, and the dropped rules come back when you leave Auto. So Emre's Bash(*) rule lets nothing skip the classifier in Auto mode; in Manual mode, on the other hand, it runs every command without asking, which is even worse.

The settings.json Layers

Permission rules and other settings are read from four files. User settings in ~/.claude/settings.json (%USERPROFILE%\.claude\settings.json on Windows) apply to you in every project on this machine. Shared project settings in .claude/settings.json are committed to the repo and reach everyone on the team. Project local settings in .claude/settings.local.json belong to you alone and to this project alone; when Claude Code first writes this file it adds it to your global git excludes, and if you created the file by hand, adding it to .gitignore is up to you. Managed settings are what the organization distributes through an MDM profile, a managed-settings.json in a system folder or the claude.ai admin console.

When the same key appears in several places, precedence from top to bottom is: managed settings, JSON passed on the command line with --settings, project local, shared project, user. But lists such as permissions.allow merge; they don't override each other. There is also an absolute rule between rules: if a tool is denied at any level, no other level can allow it. A deny in user settings blocks an allow in the project; not even --allowedTools gets past a managed deny.

Settings files are strict JSON: a comment line or a trailing comma is reported as a Settings Error at the next start. Adding a $schema line at the top gives you autocomplete and inline validation in VS Code. The Setting sources line in /status shows which files loaded, and claude doctor in the terminal lists rejected entries. When you choose "Yes, and don't ask again" on a prompt, the saved rule goes into .claude/settings.local.json at the repo root; that option changes your settings, not the team's.

Writing Rules: allow, ask, deny

Rules have the form Tool or Tool(specifier). The evaluation order is fixed: deny first, then ask, then allow; the first match decides, and how specific a rule is doesn't change that order. A Bash(git *) deny also blocks a Bash(git status) allow. In Bash rules * matches any text including spaces, and the space before the star is part of the rule: Bash(ls *) matches ls -la but not lsof; Bash(ls*) matches both. The docs recommend putting the star after the subcommand: Bash(git log *) allows only git log, Bash(git *) allows every git command.

Claude Code understands shell operators. A command joined with &&, ||, ; or | is split into subcommands and each one has to be allowed on its own; a Bash(npm test *) allow doesn't approve npm test && curl .... Wrappers such as timeout, nice and nohup are stripped before matching; but npx and docker exec are not, so a rule such as Bash(npx *) approves whatever command follows.

The docs carry an important warning: a Bash rule matches the command text Claude writes, not the same program invoked another way. A Bash(curl *) deny doesn't stop /usr/bin/curl or sh -c 'curl ...'. Bash deny rules block the form Claude usually uses; they don't draw a security boundary around the program.

File rules are written with Read(...) and Edit(...) and use gitignore syntax. //path is an absolute path from the filesystem root, ~/path is relative to your home folder, /path is relative to the settings source (the project root in project settings), and path or ./path is relative to the working directory. The trap is the single slash: /home/user/file is not an absolute path. Windows paths are converted to the /c/Users/... form before matching. A Read deny also blocks Edit and Write on the same path; a .claudeignore file, on the other hand, does nothing.

Here is the answer to the second question. Read and Edit deny rules apply to Claude's own file tools, to Bash commands Claude Code recognizes such as cat, head, tail and sed, and to redirections such as > file. They don't apply to a command that reads files without naming them (grep -r pattern . run from the folder that holds the file) or to a subprocess that reads the file internally. dotnet user-secrets list is exactly such a subprocess. That is why Bookstore's settings deny that command separately, and the real defence is elsewhere: no production connection details on a developer machine.

Bookstore: Team Settings and Personal Settings

The team's committed .claude/settings.json. Because Claude runs commands with the PowerShell tool on machines without Git for Windows, every Bash rule has a PowerShell counterpart; in PowerShell rules the command name and its aliases match case-insensitively:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "allow": [
      "Bash(dotnet build *)",
      "Bash(dotnet test *)",
      "Bash(npm test *)",
      "Bash(npm run build *)",
      "PowerShell(dotnet build *)",
      "PowerShell(dotnet test *)",
      "PowerShell(npm test *)",
      "PowerShell(npm run build *)"
    ],
    "ask": [
      "Bash(dotnet ef database update *)",
      "Bash(git push *)",
      "PowerShell(dotnet ef database update *)",
      "PowerShell(git push *)",
      "Edit(/src/Bookstore.Api/Data/Migrations/**)"
    ],
    "deny": [
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/appsettings.Production.json)",
      "Bash(dotnet user-secrets *)",
      "PowerShell(dotnet user-secrets *)"
    ]
  }
}

For the migrations folder we used ask, not deny. dotnet ef migrations add writes new files through its own subprocess, so it doesn't hit this rule; but if Claude tries to change an existing migration with the Edit tool, you are asked every time, since according to the docs a matching ask rule still prompts in Auto mode. In post seven we will write a hard block that tells new and existing migration files apart with a hook. Allow rules and additional directories in project settings also come with a trust condition: they don't apply until each teammate accepts the workspace trust dialog for the folder. Deny and ask rules apply right away because they only restrict.

Emre's personal .claude/settings.local.json. In the local file acceptEdits works as a defaultMode, auto or bypassPermissions don't:

{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(docker start bookstore-sql)",
      "PowerShell(docker start bookstore-sql)"
    ]
  }
}

User-secrets and cloud credentials live outside the repo, in your home folder, so the place to protect them is your user settings. Reading files outside the project already needs approval, but an explicit deny is definite in Auto mode too:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "deny": [
      "Read(~/AppData/Roaming/Microsoft/UserSecrets/**)",
      "Read(~/.azure/**)",
      "Read(~/.ssh/**)"
    ]
  }
}

Sandbox: A Boundary at the Operating System Level

Permission rules decide which tools Claude Code can call; the sandbox restricts the filesystem and network access of the shell command that runs and the processes it starts, at the operating system level. This is the layer that also closes the grep -r gap from the guess. By default writes are limited to the working directory, a per-user temp folder and added directories; reads are open across most of the machine, so you need to close sensitive folders with filesystem.denyRead. Network traffic goes through a proxy and only hosts in the network.allowedDomains list are allowed; the list starts empty.

The critical detail for Windows users: the sandbox works with the built-in Seatbelt on macOS and with bubblewrap and socat on Linux and WSL 2; it is not supported on native Windows, where commands run unsandboxed. If you need the sandbox, run Claude Code inside WSL 2. There you turn it on with the /sandbox command or in settings:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["~/.azure", "~/.ssh"]
    },
    "network": {
      "allowedDomains": ["api.nuget.org", "*.npmjs.org", "github.com"]
    }
  }
}

Sandbox paths are written differently from permission rules: absolute or home-relative paths, with no // prefix. One behaviour changed in 2.1.325: Bash commands running in the sandbox now go through the normal permission flow by default; if you want sandboxed commands to run without asking, set sandbox.autoAllowBashIfSandboxed to true. What the sandbox doesn't cover is clear too: Claude's Read, Edit and Write tools, WebFetch, MCP servers and hooks run outside it. If a command fails in the sandbox, Claude may ask to retry it outside; "allowUnsandboxedCommands": false closes that escape hatch.

In Production

Use Bypass permissions only in a disposable container or VM. Claude Code already refuses to run it as root or under sudo on Linux and macOS; on company machines an admin can turn it off entirely with permissions.disableBypassPermissionsMode. A developer can also lock themselves out by writing that key into their own user settings.

If you don't want an unattended session to stop and wait on a prompt, since 2.1.319 permissions.denyInsteadOfPrompting denies a call that would ask instead of waiting. The key works together with the current permission mode; Don't ask is a separate mode.

Put the rules that must hold for the whole team into managed settings. With allowManagedPermissionRulesOnly on, permission rules are read only from the managed source. Review the team settings file like code as well: a single allow line added to .claude/settings.json means a command that runs without asking on everyone's machine.

Common Pitfalls

Broad allow rules. Bash(*) opens everything in Manual mode and is dropped anyway in Auto mode; in neither case does it do what you want. Write narrow command families.

Treating deny as the only defence. A Bash rule matches command text, not the program; a Read deny doesn't stop subprocesses. Keep sensitive data off the machine, and add the sandbox where needed.

Forgetting PowerShell rules. On a machine without Git for Windows, a settings file with only Bash(...) rules matches none of the commands Claude runs.

Writing "defaultMode": "auto" into project settings and expecting it to work. auto and bypassPermissions are ignored in project and local files; use your user settings for your own default.

Writing an absolute path with a single slash. Read(/c/secrets/**) resolves against the project root; an absolute path needs //.

A Small Experiment

★☆☆ In a session, list the loaded settings sources with /status, and the active rules and the file each one comes from with /permissions. Is there a rule you didn't expect?

★★☆ Add a Bash(git *) allow and a Bash(git push *) deny to the team settings file. Explain with the "What a Bash rule doesn't match" section of the docs why a call such as git -C . push is not caught by the deny, and fix it by narrowing the rule.

★★★ Run Claude Code in WSL 2 with the sandbox on and put only the NuGet and npm hosts in network.allowedDomains. While dotnet restore and npm ci run, watch how a request to another host is handled and note what you see.

With the control layer in place, the daily workflow comes next. In post four we explore and plan first with plan mode, add a low-stock notification feature to Bookstore step by step, give a completion condition with /goal and manage context with checkpoints.