Claude Code Permissions and Safety: Understanding Approval Modes
Why Claude Code Asks Before It Acts
The first time Claude Code stops mid-task to ask "Can I run this command?", it feels like friction. You wanted an autonomous coding agent, not a chatty intern who checks in every five minutes. But that pause is the entire point. Claude Code can read your filesystem, edit files, run shell commands, and call out to MCP servers that touch your email, calendar, or production database. Without a permission layer sitting between "the model decided to do something" and "the something actually happens," you'd be handing an LLM a blank check on your machine.
Claude Code permissions exist to answer one question before every consequential action: should this specific tool call, with these specific arguments, be allowed to run right now? The answer can come from you, in the moment, via an approval prompt. Or it can come from a rule you wrote in advance, in a settings file, that says "always allow this" or "never allow that." Understanding how these two paths interact — interactive approval versus pre-configured rules — is what separates people who fight their tooling every session from people who've tuned Claude Code into something that moves fast without moving recklessly.
This article walks through the actual mechanics: the approval modes, the settings.json permission schema, how rules are evaluated, hooks for custom policy logic, and the sandboxing options that exist underneath all of it. If you're building software with Claude Code as a daily driver — which is the whole premise of learning it properly — this is the layer you need to understand before you start pointing it at anything that matters.
The Core Permission Prompt
By default, Claude Code treats most actions as requiring approval the first time they come up in a session. When Claude wants to run a shell command, write a file outside a path it's already touched, or call certain MCP tools, it surfaces a prompt describing exactly what it wants to do and why. You get options like:
- Allow once — run this one time, ask again next time
- Allow for this session — stop asking for this specific command/pattern for the rest of the current session
- Always allow — persist this as a rule so it never asks again, in any session
- Deny — block it and let Claude try a different approach
This is not a rubber stamp dialog. The prompt shows you the literal command or file path involved, so you can catch a rm -rf in a directory you didn't expect, or a curl to a domain you don't recognize, before it executes. The habit worth building is actually reading these prompts instead of reflexively clicking allow — the entire safety model depends on the human step meaning something.
Read-only operations — reading a file, listing a directory, grepping for a pattern — are generally treated as lower risk than mutating ones, but that doesn't mean zero risk. Reading a .env file and then discussing its contents in a way that gets logged or sent to a third-party MCP tool is still a real exposure. Permissions in Claude Code are about the action taken, not just whether a file gets modified on disk.
Approval Modes: Default, Plan, Accept Edits, and Bypass
Claude Code ships with a handful of distinct modes that change how aggressively it asks for approval. You cycle between them during a session (commonly bound to a keyboard shortcut like Shift+Tab), and each is suited to a different kind of work.
Default mode asks for approval on anything that isn't already covered by an allow rule. This is the safest starting point and what you should use in any repo you don't fully trust yet, or any session where you're not paying close attention.
Plan mode goes a step further in the cautious direction: Claude reads code, explores the repo, and proposes a plan, but it cannot edit files or run mutating commands at all until you exit plan mode and approve the plan. This is the right mode for "I want to see the approach before any code changes happen" — especially useful on unfamiliar codebases or high-stakes refactors.
Accept Edits mode auto-approves file edits Claude proposes, but still gates shell commands and other tool calls behind the normal prompt. This is a good middle ground once you trust Claude's judgment on the code itself but still want a human check on anything that executes.
Bypass Permissions mode (sometimes shown as a "yolo" style flag) turns off prompting almost entirely. Claude runs commands and edits files without asking. This mode exists for a reason — long unattended runs, CI-style automation, sandboxed containers where a mistake can't reach anything you care about — but it should never be your daily setting on a machine with real credentials, real repos, and real data on it. If you use it, use it inside a disposable container or VM, not on your laptop.
A practical rule of thumb: default or plan mode for anything touching production, client work, or unfamiliar code; accept-edits for well-tested personal projects where you review diffs afterward anyway; bypass only inside isolated sandboxes you can throw away.
settings.json: Where Permission Rules Actually Live
Interactive prompts are for one-off decisions. Persistent policy lives in configuration files, and Claude Code reads permission settings from a layered set of settings.json files:
- A global user settings file (applies to every project you touch)
- A project-level
.claude/settings.json(checked into the repo, shared with your team) - A local, gitignored
.claude/settings.local.json(your personal overrides, not shared)
They compose, with more specific scopes able to add to or override broader ones. The shape of the permissions block looks like this:
{
"permissions": {
"allow": [
"Bash(npm run test:*)",
"Bash(npm run lint)",
"Read(./src/**)",
"Edit(./src/**)"
],
"deny": [
"Bash(rm -rf /*)",
"Bash(curl:*)",
"Read(./.env)",
"Read(./**/secrets/**)"
],
"ask": [
"Bash(git push:*)",
"Bash(npm publish:*)"
]
}
}Each entry is a tool name paired with a pattern matching its arguments. Bash(npm run test:*) means "any bash invocation of npm run test followed by anything" is auto-allowed. Read(./.env) blocks reads of that exact file regardless of mode. The ask list is worth calling out specifically: it lets you force an interactive prompt even in accept-edits or bypass-adjacent modes for actions you consider irreversible or sensitive — pushing to a remote, publishing a package, deleting a branch — while letting everything else flow.
Deny rules always win. If a command matches both an allow pattern and a deny pattern, deny takes precedence, which is the correct default for a security boundary — permissiveness should never silently override an explicit block.
A second example, closer to what a real project might ship in its checked-in .claude/settings.json:
{
"permissions": {
"allow": [
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(pnpm test:*)",
"Bash(pnpm typecheck)",
"Edit(./app/**)",
"Edit(./tests/**)"
],
"deny": [
"Bash(git push --force:*)",
"Bash(sudo:*)",
"Read(./.env*)",
"Read(./**/*.pem)",
"Read(./**/credentials*)"
],
"ask": [
"Bash(git push:*)",
"Bash(docker:*)",
"Edit(./infra/**)"
]
}
}This kind of file is what a team should actually commit. New contributors get sane defaults on day one, secrets and force-pushes are hard-blocked for everyone, and anything touching infrastructure code still requires a human to say yes — without every git status or test run triggering a prompt.
Reading Permission Patterns Correctly
The pattern syntax deserves a closer look because getting it subtly wrong is the most common way people either over-restrict Claude Code into uselessness or under-restrict it into danger.
Patterns are matched against the specific tool invocation, not against intent. Bash(npm run build) with no trailing wildcard matches only that exact command string — npm run build --verbose would not match and would fall through to whatever broader rule or default applies. If you want to allow a whole family of subcommands, use the :* suffix convention Claude Code supports, as in Bash(npm run test:*), which matches the prefix plus anything after it.
Path-based tools (Read, Edit, Write) use glob patterns relative to the project root by default. Edit(./src/**) covers every file under src, recursively. It's worth explicitly testing the boundary cases you care about — does ./src/** also match ./src/index.ts at the top level, does it reach into node_modules if someone nests a src folder there — rather than assuming. When in doubt, write the narrower pattern and widen it deliberately once you've watched it in practice.
A mistake worth naming directly: writing an allow rule like Bash(*) because prompts got annoying. That single line removes the entire safety benefit of the allow/deny system for shell commands — every command, including ones you've never seen Claude propose before, runs without a check. If you find yourself reaching for a wildcard that broad, that's a signal to instead identify the actual handful of commands you keep approving and allow-list those specifically.
Hooks: Programmatic Control Beyond Static Rules
Static allow/deny/ask lists cover most needs, but some policies need logic — checking a file's contents before allowing a write, validating a command against a custom denylist that's too complex for a glob, or logging every tool call to an audit trail. Claude Code exposes hooks for exactly this: shell scripts that run at defined points in the tool-call lifecycle and can approve, block, or modify what happens next.
A PreToolUse hook runs before a tool executes and can veto it. Configuration lives alongside permissions in settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/Users/me/.claude/hooks/check-command.sh"
}
]
}
]
}
}The script receives the proposed tool call as JSON on stdin and communicates its decision back through its exit code and stdout — a nonzero exit or a specific JSON response blocks the action and can feed Claude a reason, which it will factor into what it tries next. A minimal version of that script might reject any command containing a raw IP address literal, or refuse edits to files matching a pattern your static deny rules don't express well, like "any file modified in the last hour outside of git."
PostToolUse hooks run after an action completes and are the right place for audit logging — writing every executed command and its result to a file you can review later, independent of whether Claude's own transcript is retained. For teams running Claude Code against shared or production-adjacent environments, a logging hook is cheap insurance: it costs nothing when everything is routine and gives you a real trail when something wasn't.
Hooks and permission rules aren't competing systems — hooks are for policy that can't be expressed as a static pattern, and permissions are for the 90% of cases that can be. Reach for a hook when you catch yourself trying to write a glob that would need actual conditionals to be correct.
Sandboxing: The Layer Underneath Permissions
Permission rules govern what Claude Code will attempt without asking. Sandboxing governs what it's physically capable of reaching even if a rule is wrong, a hook has a bug, or you fat-fingered an "always allow." These are complementary, not redundant — a well-configured permission system reduces prompts and friction; a sandbox limits blast radius when something still goes wrong.
Running Claude Code inside a container is the simplest sandbox: mount only the project directory, no host credentials, no access to your broader filesystem, network egress restricted to what the task actually needs. If a bypass-mode session goes off the rails inside a throwaway container, the damage is contained to that container. Some teams run everything — even default-mode sessions — inside a devcontainer for this reason, treating the permission prompts as the inner ring of defense and the container as the outer ring.
Git worktrees serve a related but narrower purpose: they isolate Claude's working copy of a repo from your own checkout, so an aggressive multi-file refactor or an accidental git reset --hard doesn't touch the branch you're actually working in. It's not a security boundary against a malicious action, but it is a strong boundary against a clumsy one, which in practice is the more common failure mode.
The practical takeaway is a layered mental model: permissions decide what Claude asks about, hooks decide what custom logic can veto, and sandboxing decides what's reachable at all. None of the three should be your only line of defense, especially once you start connecting MCP servers that reach into email, calendars, or payment systems — those integrations amplify what a single approved tool call can actually do downstream.
Team Settings vs. Personal Settings
On a shared codebase, the checked-in .claude/settings.json should encode the policy the whole team agrees on: hard denies on destructive git operations, hard denies on reading secret files, an ask-list for anything that touches deploys or billing. This file travels with the repo, so a new team member — or a fresh CI runner spinning up Claude Code for an automated task — inherits the same guardrails without having to rebuild them.
The local .claude/settings.local.json file is for you. This is where you add the allow rule for the specific linter command your team hasn't standardized on yet, or where you loosen something temporarily while debugging, without changing what anyone else's session does. Because it's gitignored by convention, it also keeps personal workflow preferences out of code review noise.
A subtlety worth flagging: broader-scope denies should generally win over narrower-scope allows when they conflict, which means a project's committed settings can enforce a floor that individual contributors can't accidentally loosen by editing their local file. If your team hasn't looked at what's actually in the checked-in settings file, that's worth an afternoon — it's usually the highest-leverage security review available for an AI coding setup, cheaper than almost any other audit you could run.
Practical Guardrails Worth Setting Up Today
If you're starting from Claude Code's out-of-the-box defaults, a short list of changes covers most of the risk without adding meaningful friction:
- Deny reads on all
.env*,**/secrets/**, and**/*.pempatterns, in your global settings, so this protection follows you across every project. - Deny
git push --force*and anysudo:*invocation everywhere; these are rarely needed by an agent and catastrophic when wrong. - Add an
askrule forgit push:*,npm publish:*, and any deploy script — actions that leave your machine and affect the outside world deserve a human glance even in a fast workflow. - Allow-list the specific test, lint, and build commands you run constantly, so routine work stops generating prompts.
- Add a
PostToolUselogging hook on Bash if you're running Claude Code against anything client-facing or production-adjacent — cheap to add, valuable the one time you need it. - Reserve bypass mode for containerized, disposable environments only, and default to plan mode the first time you point Claude Code at an unfamiliar or high-stakes repo.
None of this is about distrust of the model specifically — it's the same discipline you'd apply to any automation with write access to your systems, human or AI. Claude Code's permission system gives you the primitives; the settings file is where you actually write down the policy you want enforced.
Building the Habit, Not Just the Config
The settings.json examples above will get you most of the way, but the real skill is judgment about when to tighten and when to loosen. Early in a new project, default mode with a thin allow-list is right — you don't yet know what Claude will try, so you want visibility into everything. Months in, once you've watched hundreds of tool calls and know the shape of what's routine, a well-tuned allow-list with a handful of deny and ask rules lets Claude move at full speed on the 95% of actions that are genuinely low-risk, while still stopping cold on the ones that aren't.
Treat your permission configuration the way you'd treat any other piece of infrastructure: version it, review changes to it, and revisit it when your workflow changes — a new deploy pipeline, a new MCP integration, a new teammate — rather than setting it once and forgetting it exists. The prompts you see in a session are the visible tip of this system; the settings.json rules, the hooks, and the sandbox boundary are the parts doing the actual work underneath.
If you want to go deeper than a single article can cover — actually configuring settings.json across a real project, writing your first policy hook, and building the instinct for which mode fits which task — that hands-on practice is exactly what our Claude Code Tutorial for Beginners course walks through step by step.
AI CodingShip full-stack AI apps at conversation speed — specs, agents, deploys, all from the terminal.
Claude CodeGo from zero to confident with Claude Code, the terminal agent that reads, edits, runs, and verifies real code.
Related reading