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.