Chapter 17. Claude Code Permissions and the Autonomy Contract
Claude Code permissions are six modes, three rule types, and a stack of settings files that decide what a session can do without a prompt; rules evaluate deny, then ask, then allow, and the first match wins. Real autonomy takes a second document: a standing authorization at the top of my global CLAUDE.md that decides what a session should do with that room, and names the categories it can never touch. My agents merge pull requests into main and promote production deployments without asking me first, which alarms people until they read both. Autonomy on this estate is a contract with terms you can read. A mood would drift under deadline pressure; the contract holds because both halves are written down and each half names its own limits.
Six
permission modes, and what auto, dontAsk, and
bypassPermissions each allow
Claude Code ships six permission modes: default (the UI
labels it Manual, with manual accepted as an alias from
v2.1.200), acceptEdits, plan,
auto, dontAsk, and
bypassPermissions. Shift+Tab cycles from
default to acceptEdits to plan;
optional modes you have enabled slot in after plan, with
bypassPermissions first and auto last.
dontAsk never appears in the cycle. You reach it by
launching with --permission-mode dontAsk, which is the
right amount of friction for a mode whose whole behavior is
auto-denial.
Three of the six shape unattended work, and those three deserve close reading.
dontAsk is the pipeline mode. Anything that would prompt
gets denied instead: the built-in AskUserQuestion tool, connector tools
an organization set to ask, MCP tools marked
requiresUserInteraction, even when allow rules match those
tools. What still runs: permissions.allow matches, the
built-in read-only Bash commands, and approvals granted by a PreToolUse
hook. The session either has a rule covering the action or the action
dies. That property makes dontAsk the honest mode for CI:
the allowlist is the entire grant, and each gap surfaces as a denial in
the log rather than a prompt nobody is present to answer.
auto puts a classifier between the session and each
questionable action, and it hardens your rule set on entry. Broad allow
rules granting arbitrary code execution get dropped for the duration:
blanket Bash(*) and PowerShell(*), wildcarded
interpreters like Bash(python*), package-manager run
commands, and Agent allow rules. They come back when you
leave the mode. Narrow rules like Bash(npm test) carry over
untouched. The classifier also has limits: after it blocks 3 actions in
a row or 20 in a session, auto pauses and reverts to
prompting, and the thresholds are not configurable; in -p
non-interactive runs, repeated blocks abort the session instead.
claude auto-mode defaults prints the full allow and block
lists as JSON, and administrators widen them through the
autoMode.environment setting. Since v2.1.210 the classifier
runs on Claude Sonnet 5 by default rather than the session’s
/model selection, falling back to the session model when
that model is Sonnet 4.6 or when availableModels excludes
Sonnet 5, and to an Opus model when the session runs on Fable 5.
bypassPermissions is the mode people describe as “no
brakes,” and the docs are specific about which brakes stay on. Explicit
ask rules still prompt. Connector tools an organization set
to ask still prompt. MCP tools marked
requiresUserInteraction still prompt (v2.1.199 and later).
Removals aimed at the filesystem root or the home directory,
rm -rf / and rm -rf ~, still prompt, and as of
v2.1.208 that covers attempts smuggled in through $(...),
backticks, or <(...) substitution. The flag
--dangerously-skip-permissions is the same mode by another
route, and Claude Code refuses to start with it as root or under
sudo on Linux and macOS, skipping the check only inside a
recognized sandbox. --allow-dangerously-skip-permissions
adds the mode to the Shift+Tab cycle without turning it on. If you want
the mode gone, permissions.disableBypassPermissionsMode
takes the value "disable" and works from any settings
scope; permissions.disableAutoMode does the same for
auto.
Rule evaluation order: deny, then ask, then allow
Rules evaluate in one fixed order: deny, then ask, then allow. The first match wins, and rule specificity does not reorder anything. A broad deny cannot carry allowlist exceptions, so carve-outs mean writing several narrow denies instead of one wide one. This ordering is the most load-bearing fact in the system: a deny at the bottom of the settings precedence stack still beats an allow at the top.
A bare tool-name deny such as Bash, or its equivalent
Bash(*), does more than block calls. It removes the tool
from the model’s context. The one exception is EndConversation, which a
deny rule can’t remove while any other tool remains, and which an ask
rule never prompts for.
Deny and ask rules can match a top-level scalar input parameter with
the Tool(param:value) shape:
Agent(model:opus), Agent(isolation:worktree),
Bash(run_in_background:true). Allow rules cannot.
Canonicalized fields sit outside parameter matching because they have
dedicated syntaxes: command on Bash and PowerShell,
file_path on Read, Edit, and Write, path on
Grep and Glob, notebook_path on NotebookEdit,
url on WebFetch.
Read and Edit rules anchor paths four ways: //path from
the filesystem root, ~/path from home, /path
relative to the settings file that defines the rule, and
path or ./path relative to the current
directory. Patterns use gitignore syntax, and depth behavior differs by
rule type. As an allow rule, Edit(src/**) matches only
<cwd>/src; as a deny or ask rule, the same pattern
matches a src directory at any depth;
Edit(**/src/**) reaches any depth in all three types. The
asymmetry leans the correct way: grants stay narrow by default,
restrictions reach far by default.
How
Bash() patterns match, and the wrappers that slip
through
Bash rules accept * at any position, and one space
changes the match. Bash(ls *) enforces a word boundary and
matches ls -la but not lsof;
Bash(ls*) matches both. The :* suffix equals a
trailing * and is recognized only at the end of a pattern.
Compound commands split on &&, ||,
;, |, |&, &,
and newlines, and a rule must match each subcommand on its own;
approving a compound command saves up to 5 separate rules.
Before matching, Claude Code strips a fixed set of wrappers:
timeout, time, nice,
nohup, stdbuf, the shell builtins
command and builtin, zsh’s
noglob, and bare xargs with no flags. It does
not strip command -v or nocorrect, and the
list is built in, not configurable. Four exec wrappers cannot be
pre-approved by a prefix rule at all: watch,
setsid, ionice, and flock prompt
on each use, as does find carrying -exec or
-delete. Environment runners pass through unstripped, and
that is the hole to respect: direnv exec,
devbox run, mise exec, npx, and
docker exec keep their arguments opaque to the matcher, so
Bash(devbox run *) approves anything after
run. Write that rule and you have allowlisted arbitrary
execution while feeling precise.
A built-in read-only set never prompts regardless of rules:
ls, cat, echo, pwd,
head, tail, grep,
find, wc, which,
diff, stat, du, cd,
and read-only forms of git. That set is not configurable
either, and commands longer than 10,000 characters prompt no matter what
they contain.
WebFetch rules use a domain: prefix matched
case-insensitive against the hostname.
WebFetch(domain:*.example.com) covers subdomains at any
depth but not the apex, and a wildcard anywhere other than a leading
*. or a bare * matches only text between two
dots. MCP rules split by type: allow rules accept tool-name globs only
after a literal mcp__<server>__ prefix with a
glob-free server segment, and unanchored allow globs such as
"*", "B*", or "mcp__*" are
skipped with a warning. Deny and ask rules take full-name globs, so
"mcp__*" shuts off the entire MCP surface with one string.
The grammar repeats the path asymmetry: broad restriction is cheap,
broad grant is refused.
Hooks sit underneath all of it. A PreToolUse hook that exits with
code 2 stops the tool call before rules are consulted, which means it
overrides allow rules; a hook’s “allow” decision does not bypass deny
rules, ask rules, connector tools set to ask, or
requiresUserInteraction tools. Restriction composes across
layers. Permission does not.
The seven
permissions.* keys and settings precedence
Permission configuration is seven keys:
permissions.allow, permissions.ask,
permissions.deny,
permissions.additionalDirectories,
permissions.defaultMode,
permissions.disableBypassPermissionsMode, and
permissions.disableAutoMode. Precedence between settings
sources, highest first: managed settings, which even command line
arguments cannot override; command line arguments;
.claude/settings.local.json;
.claude/settings.json;
~/.claude/settings.json. Precedence settles which scope’s
configuration applies, but it never rescues an allow: a deny at any
level cannot be overridden by an allow at another.
Managed settings live at
/Library/Application Support/ClaudeCode/managed-settings.json
on macOS, /etc/claude-code/managed-settings.json on Linux
and WSL, and
C:\Program Files\ClaudeCode\managed-settings.json on
Windows, each with a sibling managed-settings.d/ drop-in
directory. I checked this machine on 2026-07-27:
/etc/claude-code and
/Library/Application Support/ClaudeCode/ both do not exist.
No policy layer sits above me here. The floor on this estate is a floor
I wrote, which is the reason I keep it in a file instead of in my head.
A floor carried in your head moves when you are tired.
The shape of a permissions block, using the syntax above:
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(npm test)",
"Bash(npx tsc --noEmit)",
"WebFetch(domain:code.claude.com)"
],
"ask": [
"Bash(dangerouslyDisableSandbox:true)",
"Agent(model:opus)"
],
"deny": [
"Read(~/.ssh/**)",
"Read(~/.aws/**)"
]
}
}The block above is illustrative. The real
~/.claude/settings.local.json on this machine carries an
allow list of 17 entries and nothing else, and the entries
are narrower than the example: Bash(gh search *),
Bash(python3 -c ' *), and a named Chrome MCP tool among
them. Local settings accumulate as narrow grants earned one prompt at a
time, which is why they belong in the untracked tier rather than in a
file your team shares.
The ask rule on Bash(dangerouslyDisableSandbox:true) is
worth copying as written: when a sandboxed command fails, Claude may
retry it with the dangerouslyDisableSandbox parameter, and
that ask rule forces a prompt on each such retry. The Read denies exist
because the default posture does not protect key material on its own.
The sandbox has parallel filesystem keys
(sandbox.filesystem.denyRead among them), and its default
read scope covers the whole filesystem except denied paths,
~/.aws/credentials and ~/.ssh/ included. Two
layers, two deny lists, same paths in both.
Three behaviors around these files change how you write them.
Trust gates grants. permissions.allow rules and
additionalDirectories in a project’s
.claude/settings.json apply only after the workspace trust
dialog is accepted; deny and ask rules apply regardless, because they
only restrict. Trust keys on the git repository root, or on the start
directory outside a repo. A cloned repository can ship you restrictions,
but it cannot ship itself permissions.
Approvals persist repo-wide. As of v2.1.211, an approval saved with
“don’t ask again” writes to .claude/settings.local.json at
the git repository root, resolved through worktrees to the main
checkout, so it covers the whole repository; earlier versions saved it
in the starting directory. On this machine, where sessions run in
sibling worktrees cut from origin/main as a standing rule,
that resolution is the difference between approving a command once per
repo and once per worktree.
Protected paths ignore your allow rules. Writes to .git,
.config/git, .vscode, .idea,
.husky, .cargo, .devcontainer,
.yarn, .mvn, and .claude (with
.claude/worktrees excepted) are never auto-approved, and
the protected-file list runs from the shell startup files through
package-manager configs to .mcp.json and
.claude.json. permissions.allow does not
pre-approve any of them. Resolution depends on the mode:
| Mode | Protected-path write |
|---|---|
default, acceptEdits |
Prompt |
plan |
Prompt on this machine’s version; v2.1.217 and v2.1.218 add gated variants |
auto |
Routed to the classifier |
dontAsk |
Denied |
bypassPermissions |
Allowed |
The list is one idea applied file by file: anything that decides what
runs next gets a human check even when the rest of the session is
pre-approved. The sandbox backs the same idea from below, denying writes
to Claude Code’s own settings.json at every scope and
resolving symlinks planted at those paths as of v2.1.210. A sandboxed
command cannot loosen the leash that holds it.
Settings files carry more than permission rules. On this estate, each
new public repo I scaffold gets a committed
.claude/settings.json registering my skills
marketplace:
{
"extraKnownMarketplaces": {
"suede": {
"source": { "source": "github", "repo": "JasonColapietro/suede-creator-skills" }
}
},
"enabledPlugins": { "suede-skills@suede": true }
}The rules around that file are the transferable part. Merge with
jq when the file already has other keys, and do not
overwrite what is there. Check .gitignore first for a
blanket .claude/ line and narrow it to
.claude/* plus !.claude/settings.json, so
worktree scratch and settings.local.json stay ignored while
the shared file tracks. A committed settings file is policy that travels
with the clone, which is the point, and also the reason the trust dialog
exists.
Letting an agent merge and deploy without asking
Claude may create and merge PRs and deploy to production for my own
projects, Suede and the rest, including pushing to main and
promoting or aliasing production deployments, without prior per-action
sign-off. That standing authorization is the first section of my global
CLAUDE.md, above the identity rules, the Vercel rules, and everything
else in the file. The instruction is direct: do not ask for approval
before merging or deploying; the file tells the agent to “just ship and
then tell Jason what landed.” Position is part of the design. First rule
in the file means first thing a session reads.
Two clauses turn the grant into a contract.
The first is the supersession clause. The rule states that no check-in-before-merge requirement exists, and that any such instruction appearing in a one-off task prompt is superseded by the standing rule. Task prompts accumulate cautious boilerplate, and an agent that respects prose will stall on an approval gate I did not mean to impose. A contract that any passing document can amend is not a contract. This one names the single place its terms change: the file itself.
The second is the floor, stated in the same paragraph as the grant. The base safety prohibitions still apply: credentials, payments and transfers, deleting data, and access-control or permission changes. Routine PR merges and app deploys are what the authorization covers, and nothing else rides along with them. Grant and floor share a paragraph on purpose, so anyone quoting the grant reads the floor in the same breath. The floor also does not move with my week. On a confident Friday I might feel like widening it; the file was written on a clear-headed day, and the file wins.
The authorization carries a reporting duty. Ship, then say what landed: an agent that merges and reports nothing has broken the contract as much as one that stalls a routine deploy waiting for a yes. My thread-ending rule runs parallel to it. A thread that changed code or files finishes with a commit, or it states why committing was skipped. The escape hatch is written into the rule, so silence fails there too.
The same file overrides harness defaults in the opposite direction. Claude Code’s own git guidance ends commit messages with a Co-Authored-By: Claude trailer and PR bodies with a Generated-with line. My file forbids both without exception, because I am a solo founder and the work ships under my name; the name is part of what the work builds. A policy file cuts both ways: it grants room the defaults withhold, and it strips ceremony the defaults impose.
Two layers, and the version gate under both
The permission system and the CLAUDE.md contract are different layers, and the estate needs both. Rules without a standing grant produce an agent that stops at each merge, and across 20-plus live surfaces that makes me the bottleneck I brought agents in to remove. A standing grant without rules asks a language model to hold prose boundaries while a task prompt or a fetched page pushes against them; deny rules, rule ordering, and protected paths hold regardless of how persuasive the input is. The mechanism bounds what a session can do without asking. The contract directs what it should do inside those bounds. The floor appears in both, once as prohibition categories in prose and once as denies and protected paths the model cannot argue with.
Contracts also carry a version number. claude --version
on this machine, run 2026-07-27, reports 2.1.211. Doc features gated at
v2.1.212 and later are inert here: the plan-mode ask-rule behavior,
sandbox.filesystem.disabled (v2.1.216), the plan-mode
classifier routing (v2.1.218), and
sandbox.network.strictAllowlist (v2.1.219) read fine on the
page and do nothing on this binary. The repo-wide approval save landed
at 2.1.211 on the nose, so that one I have. Before I write a rule that
depends on a documented behavior, I check the gate against the installed
version, the same reflex as curling production instead of trusting the
file tree. The docs describe the newest contract. The binary on your
machine is the one you signed.