The permission model: Claude Code's most underrated feature
Every Claude Code user knows the prompt: the agent wants to run a command, you press allow, work resumes. Most users' entire relationship with the permission model is finding ways to see that prompt less often, up to and including the nuclear flag that skips permissions entirely. That is backwards. Underneath the prompt sits the most granular permission system shipping in any mainstream coding agent, and almost nobody uses it deliberately.
The prompt is what you see when you haven't configured the feature.
The mechanics
Permissions live in layered settings files: ~/.claude/settings.json for you-everywhere, .claude/settings.json for the repo (checked in and shared with the team), and .claude/settings.local.json for personal per-repo overrides. Rules name a tool and optionally narrow it:
{
"permissions": {
"allow": ["Bash(npm test:*)", "Bash(git diff:*)", "Edit"],
"deny": ["Read(./.env)", "Bash(curl:*)"]
}
}
Three properties do the heavy lifting:
- Two levels of granularity. Tool-level rules ("Edit is fine in this repo") and command-level rules with prefix matching ("
npm testyes,npm publishnever"). You are trusting specific verbs, not bash as a whole. - Deny beats allow. Deny rules hold even when an allow rule is sloppy. Denying
Readon.envmeans the agent cannot pull your secrets into context, even though reading files is otherwise its entire job. - Modes change posture wholesale. Default asks before mutations; accept-edits pre-approves file changes while commands still ask; plan mode is read-only by construction; bypass exists for containers where isolation comes from outside the process. And sandboxed bash wraps commands in OS-level filesystem and network isolation. Allow gets a command past the prompt; the sandbox bounds what past-the-prompt can mean.
A trust profile, not a reflex
Here is the reframe that changed how I configure this. Every "allow always" click is a security policy decision made in half a second under deadline pressure. Fifty of those and your real policy is "whatever I clicked while annoyed," documented nowhere. The settings file inverts it: you write the trust profile down, per repo, and review it like code. It is code, after all. A checked-in project file means the whole team shares one deliberate posture, and the new hire's agent is exactly as constrained as the founder's.
Permission prompts are policy decisions arriving one click at a time; settings.json is where you finally write the policy down.
Compare the neighbors: Codex's approval modes are a handful of coarse dials, and Gemini CLI's safety story leans on sandboxing plus confirmation prompts. Both are reasonable. Neither lets you express "tests yes, publishes never, and this file is invisible," then commit that sentence to the repo.
Where it leaks
Be clear about the limits. Prefix matching is syntactic, not semantic: Bash(git diff:*) looks read-only, but shell composition, aliases, and scripts-that-call-scripts can smuggle side effects behind an innocent prefix. Claude Code inspects compound commands, but the general problem is unsolved. Deny rules need to cover side doors too: blocking Read(./.env) while allowing broad Bash leaves cat as a workaround. And no permission rule stops prompt injection from steering the agent toward tools you already allowed: rules gate capabilities, not intent. That is why permissions and sandboxing are complements, not competitors: rules decide what the agent may attempt, the sandbox bounds what an attempt can do.
Build the profile empirically. Run a normal week in default mode, notice which prompts you approved every single time, and promote exactly those into allow. Add deny rules for the three things that would actually hurt: secrets files, network exfiltration paths, destructive git. It is an hour of work, once per repo. That is cheaper than the alternative: training yourself to click allow without reading, right up until the one time it mattered.