Claude Code permissions: the guide I wish the docs were
6m read time

Claude Code permissions: the guide I wish the docs were

How Claude Code permissions actually work: modes, allow and deny rules, hooks and the sandbox as one mental model, plus the settings.json I run.

Anthropic measured what happens when Claude Code asks for permission: users approve 93% of the prompts. At that rate the prompt is furniture. You stop reading it, your finger learns the shortcut, and the one prompt that mattered gets the same reflexive yes as the forty that didn't.

The popular fix makes it worse. --dangerously-skip-permissions is the same decision with the button removed: you were already saying yes to everything, now you just admit it. And the incident reports write themselves, wiped home directories, deleted databases, a long tail of people learning the hard way that an agent with your shell is exactly as careful as its worst sample.

The actual fix is understanding that Claude Code's permission system is four layers, and each layer answers a different question. Once you see that, configuring it takes twenty minutes and the prompts you still get are the ones worth reading.

The four layers

LayerQuestion it answers
ModeHow often does it ask?
RulesWhat may it call?
HooksWhat gets enforced, deterministically?
SandboxWhat can the process physically reach?

Modes and rules shape the model's behaviour. Hooks and the sandbox enforce yours. Most guides stop after the first two, which is how people end up with an allowlist they believe is a security boundary. It isn't a boundary. It's a lock on a door the agent politely uses.

Layer 1: modes decide how often it asks

The mode is a session-level switch, set with Shift+Tab, --permission-mode, or defaultMode in settings:

  • default: reads run free, everything else prompts. Where you start.
  • plan: read-only exploration. The agent proposes, you dispose.
  • acceptEdits: file edits are auto-approved, shell commands still prompt. My daily driver for repos with good git hygiene, because git checkout . undoes an edit but nothing undoes a shell command.
  • auto: a safety classifier approves everything it considers harmless. Anthropic reports a 0.4% false-positive rate and a 17% false-negative rate. One in six risky calls sailing through is still a better deal than the 93% reflex-yes, but know what you're buying.
  • bypassPermissions: everything runs. Acceptable inside a disposable container, reckless on the machine with your SSH keys.

A mode is not persisted per project by the flag you launched with. People run --dangerously-skip-permissions once, resume the session the next day, and wonder why the prompts are back. Set defaultMode in settings if you want it sticky.

Layer 2: rules decide what it may call

The permissions block in settings.json takes three arrays, evaluated strictly in this order: deny, then ask, then allow. First match wins, and specificity does nothing:

json
{
  "permissions": {
    "allow": ["Bash(aws s3 ls)"],
    "deny": ["Bash(aws *)"]
  }
}

That allow rule is dead code. The deny matches first, so every aws command is blocked, including the one you allowed. Deny beats ask beats allow, always.

The syntax has teeth once you learn where it bites:

  • The space in the wildcard matters. Bash(ls *) matches ls -la and enforces a word boundary. Bash(ls*) also matches lsof. One character of difference, wildly different blast radius.
  • Compound commands must match per part. Bash(git *) does not approve git add . && rm -rf /tmp/x. Every subcommand chained with &&, ; or pipes needs its own matching rule. This is why your allowlist still prompts constantly: the model loves chaining commands.
  • Path anchors are subtle. In Read and Edit rules, //etc/passwd is an absolute path, /src/** anchors at the settings file's project, ./ is the working directory. A single leading slash does not mean what your shell reflexes say it means.
  • Read deny rules only bind the Read tool. "deny": ["Read(.env)"] stops the agent's file reader. It does nothing about a script the agent runs that reads .env itself. Rules constrain tool calls, never the code those calls execute.

That last point is the whole argument for the next two layers.

Layer 3: hooks are the deterministic layer

A PreToolUse hook is a shell command that sees every tool call before it runs and can kill it, unconditionally, no model judgement involved. Exit code 2 blocks the call. A JSON response can force allow, ask or deny:

bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ "$FILE_PATH" == *".env"* ]]; then
  echo "Blocked: secrets stay out of context" >&2
  exit 2
fi
exit 0

Rules are patterns the permission system tries to match against a command string. A hook is your code, running with the full tool input, applying whatever logic you want. When the allowlist regex fails you (and the compound-command gotcha above guarantees it eventually will), the hook still fires.

I wrote a full guide to Claude Code hooks if you want the complete lifecycle. For permissions, PreToolUse is the one that matters. If you're unsure whether a rule, a hook or something else fits your case, there's a decision guide for skills, subagents, hooks and slash commands too.

Layer 4: the sandbox is the wall

Everything above lives in Claude Code's process and negotiates with the model. The sandbox is OS-level: filesystem and network isolation around the shell commands themselves. Anthropic's own framing is the right one, permissions are the lock on the door, the sandbox is the wall.

json
{
  "sandbox": {
    "enabled": true,
    "network": { "allowedDomains": ["registry.npmjs.org", "api.github.com"] },
    "filesystem": { "denyRead": ["~/.ssh", "~/.aws"] }
  }
}

With the sandbox on, a script that tries to read ~/.ssh fails at the OS, no matter what the model intended, no matter what the rules said. The default behaviour is a nice trade: sandboxed commands run without prompting, anything that needs to leave the sandbox falls back to the permission flow. Fewer prompts and a harder boundary at the same time.

The settings.json I actually run

Project-level, checked into the repo, tuned per project. Annotated:

json
{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(npm run test:*)",
      "Bash(npm run build)",
      "Bash(npx vitest run:*)",
      "Bash(git status)",
      "Bash(git diff:*)",
      "Bash(git log:*)"
    ],
    "ask": ["Bash(git push:*)"],
    "deny": [
      "Read(.env)",
      "Read(.env.*)",
      "Read(~/.ssh/**)",
      "Bash(curl:*)",
      "Bash(wget:*)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "./.claude/hooks/protect-files.sh" }]
      }
    ]
  }
}

The logic: edits are cheap to undo, so acceptEdits. The test and build loop runs free, because a prompt on every npm run test is how the 93% reflex gets trained. git push always asks, because that's the moment work leaves the machine.

Secrets are denied at the rules layer and enforced again by the hook, and curl/wget are denied because the agent has WebFetch with a domain allowlist for that. Two layers saying the same thing is the point: the rule shapes what the model attempts, the hook catches what slips past.

The same principle scales down into your data. Giving the agent database access with SELECT-only credentials and field redaction is this exact stack applied one layer deeper, and I've written up how I give Claude safe access to a SQL database as the worked example.

Twenty minutes of configuration, and the prompt count drops from forty a session to a handful. Read those. They're the ones that earn it.

series: Claude Pro(16 of 16)