The first time I ran Claude Code on a real task — migrate a Spring Boot service, restructure a few packages, update some config — I spent more time clicking Approve than I did reviewing what it actually did.
Every file read. Every git status. Every ls. Approve. Approve. Approve.
By the time Claude finished, I'd clicked through forty-something prompts and was no closer to trusting what had changed than when I started.
The permission system was doing the opposite of what it should: instead of giving me control, it had trained me to rubber-stamp everything without reading it.
That's the problem this post is about. Not how to skip permissions entirely — that's a different conversation — but how to configure them so you're approving things that actually matter, and not thinking twice about the ones that don't.
Why the permission system exists
Claude Code is not a chat window. It can read files, write files, run arbitrary shell commands, call external services through MCP, and chain all of that into multi-step work without stopping between steps. That's what makes it useful for real tasks. It's also why a blanket "just approve everything" is a bad idea on any machine you care about.
The permission system sits between Claude's decisions and your filesystem. Every time Claude wants to do something that could modify state — write a file, run a bash command, push to a remote — the harness checks whether it's allowed to proceed automatically, needs to ask you, or should be blocked outright.
Get the configuration right and you spend your attention on the decisions that actually need it. Get it wrong and you either click yourself numb or give Claude the keys to everything.
The four permission modes
Before rules, there are modes. The mode is the default behaviour for anything that doesn't match a specific rule.
| Mode | Reads | Edits | Bash | Best for |
|---|---|---|---|---|
default |
Auto | Asks | Asks | Safe starting point, new projects |
plan |
Auto | Blocked | Blocked | Exploring or reviewing a codebase |
acceptEdits |
Auto | Auto | Asks | Active development sessions |
bypassPermissions |
Auto | Auto | Auto | CI/CD pipelines and isolated containers |
You switch modes mid-session with the /permissions command. You set the default in settings.json:
{
"permissions": {
"defaultMode": "acceptEdits"
}
}
Valid values are default, plan, acceptEdits, dontAsk, and bypassPermissions.
Where settings live
Claude Code reads configuration from multiple locations, in order of priority from highest to lowest:
Enterprise managed policy → ~/.claude/managed-settings.json
User settings → ~/.claude/settings.json
Project settings → .claude/settings.json
Project local (gitignored) → .claude/settings.local.json
Higher scope wins. If a managed policy denies a permission, nothing in your user or project settings can override it. If your project settings deny Bash(curl *), your user settings cannot allow it back.
Rule of thumb: commit
settings.jsonso the whole team gets the same base config. Put personal overrides insettings.local.jsonand keep it gitignored.
Allow and deny rules
Rules live inside the permissions object and follow a consistent format: either a bare tool name, or a tool name with a specifier in parentheses.
{
"permissions": {
"allow": ["Bash(npm run *)"],
"deny": ["Bash(rm *)"],
"ask": ["Bash(git push *)"]
}
}
⚠️ Evaluation order: deny → ask → allow. The first match wins, and deny always wins. A narrow allow rule cannot override a broader deny rule. If you write
deny: ["Bash(aws *)"]and thenallow: ["Bash(aws s3 ls)"], the allow rule never fires.
Bare tool name vs scoped rule behave differently. This is the part that catches people out.
A bare tool name like "Bash" in the deny list removes the tool from Claude's context entirely. Claude never sees it exists. A scoped rule like "Bash(rm *)" leaves the tool available but blocks matching calls when Claude attempts them.
Use bare names when you want to disable a tool completely. Use scoped rules when you want the tool available but constrained.
The tools you can target
The main tools you'll write rules for:
| Tool | What it covers |
|---|---|
Bash |
Shell command execution — by far the most important to configure |
Read |
Reading files |
Edit |
Editing existing files |
Write |
Creating new files |
Grep |
Searching file contents |
Glob |
Listing files by pattern |
WebFetch |
Fetching URLs |
mcp__<server>__<tool> |
Any MCP server tool |
For Bash rules, the specifier is a glob matched against the full command string: Bash(git *) matches any git command, Bash(npm run *) matches any npm script, Bash(git push *) matches git pushes specifically.
Practical configurations for real scenarios
Daily development on a project you own
Use this when you're in an active coding session and have already agreed on the plan with Claude. You want edits to flow freely but still get a pause before pushes and destructive commands.
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(git status)",
"Bash(git log *)",
"Bash(git diff *)",
"Bash(npm run *)",
"Bash(npm test)",
"Bash(mvn test)",
"Bash(./gradlew test)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(curl *)",
"Bash(wget *)"
],
"ask": [
"Bash(git push *)",
"Bash(git commit *)"
]
}
}
Exploring or reviewing an unfamiliar codebase
Use this when you want Claude to analyse and explain but not touch anything — onboarding to a new repo, doing an architecture review, or talking through a design before agreeing on what to change.
{
"permissions": {
"defaultMode": "plan",
"allow": [
"Read",
"Grep",
"Glob"
]
}
}
CI/CD pipeline (GitHub Actions)
Use this for unattended runs in an isolated environment. Even in bypass mode, deny rules still fire — use them to block anything that could escape the container.
{
"permissions": {
"defaultMode": "bypassPermissions",
"deny": [
"Bash(git push *)",
"Bash(curl *)"
]
}
}
Protecting sensitive files regardless of mode
Use this to prevent Claude from ever reading credentials or environment files, no matter what mode you're in.
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env*)",
"Bash(cat .env*)"
]
}
}
The /permissions command
Instead of editing JSON by hand every session, use the built-in /permissions command inside Claude Code. It opens an interactive UI where you can:
- See your current mode
- Switch modes for the current session
- Add allow or deny rules that persist to
settings.local.json - Review what's been auto-approved so far
For anything you find yourself approving repeatedly in a session, it's faster to add it through /permissions than to keep clicking.
CLI flags for one-off sessions
If you don't want to touch settings.json, you can set rules for a single session at startup:
# Allow specific tools for this session only
claude --allowedTools "Read,Grep,Glob,Bash(git log*),Bash(git diff*)"
# Block specific tools for this session only
claude --disallowedTools "Bash(rm *),Bash(curl *)"
# Set the permission mode for this session
claude --permission-mode acceptEdits
# Headless one-shot task with no prompts (CI use case)
claude -p "Run all tests and report failures" \
--permission-mode bypassPermissions \
--disallowedTools "Bash(git push *)"
Flags apply only to the current session and don't modify any settings file.
The one thing most people get wrong
:::warning
bypassPermissions does not make your deny rules irrelevant. Deny rules are evaluated before the permission mode — they always fire, even in bypass.
:::
This is the mechanism that makes bypass safe in CI: you can allow everything except the specific things that could escape the environment.
What bypassPermissions does is skip the "ask" step for everything that isn't explicitly denied. So the right pattern for a CI pipeline is not "bypass with no deny rules" — that's YOLO mode — but "bypass with deny rules for anything that could cause damage outside the container."
Where to go next
The official documentation covers hooks (PreToolUse, PostToolUse) which let you run custom shell commands at each tool invocation — useful for enforcing things the rule syntax can't express, like blocking edits to files that match a pattern across multiple tools at once.
The /permissions command and settings.json are enough for most workflows. Start there, pay attention to what you're clicking approve on in your first few sessions, and add rules for anything you approve more than twice. Within a week you'll have a configuration that fits how you actually work — and you'll stop rubber-stamping things you haven't read.
This is part of my series on practical AI tooling for software architects. If something here was useful, or if you've found a permissions pattern that works well for your workflow, I'd like to hear about it in the comments.
Top comments (0)