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
| Layer | Question it answers |
|---|---|
| Mode | How often does it ask? |
| Rules | What may it call? |
| Hooks | What gets enforced, deterministically? |
| Sandbox | What 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:
{
"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 *)matchesls -laand enforces a word boundary.Bash(ls*)also matcheslsof. One character of difference, wildly different blast radius. - Compound commands must match per part.
Bash(git *)does not approvegit 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/passwdis 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. Readdeny 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.envitself. 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:
#!/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 0Rules 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.
{
"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:
{
"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.