Updated April 9, 2026: The original version recommended granular Bash(command:*) patterns. After a week of real-world testing, I found that approach fails for compound commands. This revision recommends Bash(*) with a deny list — simpler and more reliable.
I was in the middle of a production release — squash-merging 33 commits, pushing to three remotes, monitoring Slack channels for user reports — when Claude Code asked me for the fourteenth time whether it could run gcloud logging read. I had already approved it. Multiple times. In the same session.
Each approval prompt breaks flow. You read the command, decide it’s safe, click approve, and by the time you’re back in context, the agent has moved on to the next step and is asking for permission again. Multiply that by every git log, every grep, every curl, every Slack channel read, and you’re spending more time on permission dialogs than on the actual work.
The irony is sharp: the tool designed to accelerate my workflow was decelerating it through excessive caution. And the default behavior that protects new users was punishing experienced ones.
I fixed it — after a false start that taught me something useful about how Claude Code actually evaluates commands. Here is what I tried first, why it failed, what works, and ready-to-use templates you can start with today.
The three-tier permission architecture
Claude Code uses a scope hierarchy for configuration. Most users only know about the first level. The full system has four tiers, three of which matter for individual practitioners:
| Scope | File location | Who it affects | Shared? |
|---|---|---|---|
| User | ~/.claude/settings.json | You, everywhere | No |
| Project | .claude/settings.json in repo | All collaborators | Yes (git) |
| Local | .claude/settings.local.json in repo | You, this repo only | No (gitignored) |
| Managed | System-level | All users on machine | Yes (IT) |
More specific wins: Local > Project > User. If a permission is allowed in your user settings but denied in project settings, the project setting takes precedence. Deny always wins over allow at any level.
This layering is the key insight. You configure once at the right level and never think about it again:
- User scope for your personal development toolkit — the commands you run in every project
- Project scope for team-shared conventions — what every collaborator on this repo needs
- Local scope for your personal overrides on a specific project — your credentials, your paths, your tools
The granular approach — and why it doesn’t work
My first instinct was to approve commands by category. List every tool Claude Code might use and create a pattern for each one:
{
"permissions": {
"allow": [
"Bash(git:*)", "Bash(gh:*)", "Bash(gcloud:*)",
"Bash(node:*)", "Bash(npm:*)", "Bash(python:*)",
"Bash(ls:*)", "Bash(grep:*)", "Bash(find:*)",
"Bash(curl:*)", "Bash(jq:*)"
]
}
}
This looks clean and intentional. I built templates with 80+ patterns covering everything from Bash(tar:*) to Bash(dig:*). It seemed like the right approach — allow what’s safe, block everything else by omission.
It fails in practice. Claude Code frequently runs compound commands:
cd /path/to/repo && git fetch origin && git log --oneline HEAD..origin/main | head -5
This single command chains cd, git, git again, and head through && and pipes. Even with all four patterns individually allowed, the compound command triggers a permission prompt because the permission system evaluates the full command string, not each subcommand independently.
I spent a week testing different syntaxes — colons, spaces, wildcards — before accepting that granular bash patterns and compound commands don’t mix. Since compound commands are how Claude Code actually works (and for good reason — chaining related operations is more efficient than running them separately), the granular approach creates exactly the interruption it’s trying to prevent.
The real solution: Bash(*) and a deny list
The answer is simpler than what I started with. Allow all bash commands, then explicitly deny the few that are genuinely dangerous:
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read(*)", "Write(*)", "Edit(*)",
"Bash(*)",
"WebFetch(*)", "WebSearch(*)"
],
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf /*)"
]
}
}
This is my actual working configuration. No compound command failures, no missed patterns, no friction.
The deny list is intentionally short. It covers catastrophic filesystem destruction — the commands where a mistake isn’t recoverable. Everything else is recoverable, reviewable in git, or sandboxed to a development environment.
defaultMode — the setting most people miss
Notice "defaultMode": "acceptEdits" at the top of the permissions block. Without it, Claude Code runs in its default permission mode, which prompts for everything except file reads — regardless of your allow list. This was the hidden flaw in my original configuration, and it took longer to diagnose than the compound command problem.
The permission modes:
| Mode | Auto-approves |
|---|---|
default | Read only — everything else prompts |
acceptEdits | Read, Write, Edit — bash checked against allow list |
bypassPermissions | Everything |
acceptEdits is the sweet spot. File operations flow without prompts. Bash commands are checked against your allow and deny lists. You keep control over external actions while eliminating the constant friction of approving file reads and writes.
What to keep gated
With Bash(*) allowed, your deny list and the tools you deliberately omit from the allow list become your safety boundaries.
Deny explicitly — catastrophic filesystem operations:
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf /*)"
]
Omit from allow — externally-visible MCP writes:
Slack message sending (mcp__slack__slack_send_message) is the primary example. Messages sent via the API are tagged “sent using Claude,” cannot be edited or deleted by you, and represent you publicly. Pre-approve reads, not writes.
Production deployments are covered by Bash(*) since they’re bash commands. If you want deploy commands gated, add them to your deny list. I handle this through workflow discipline rather than permission rules: Claude Code doesn’t deploy unless I explicitly ask it to.
MCP tools need their own entries
MCP server tools (Slack, Playwright, Notion, Gmail, etc.) aren’t covered by Bash(*). Each integration needs explicit entries:
"mcp__slack__slack_read_channel",
"mcp__slack__slack_read_thread",
"mcp__slack__slack_search_public",
"mcp__plugin_playwright_playwright__*"
The * wildcard works for MCP tool names, so mcp__plugin_playwright_playwright__* covers all Playwright operations in a single rule. Apply the same principle: pre-approve reads, gate writes.
The global template
Here is my ~/.claude/settings.json. The GitHub gist has the full file.
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read(*)", "Write(*)", "Edit(*)",
"Bash(*)",
"WebFetch(*)", "WebSearch(*)",
"mcp__slack__slack_read_channel",
"mcp__slack__slack_read_thread",
"mcp__slack__slack_search_public",
"mcp__plugin_playwright_playwright__*"
],
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf /*)"
]
}
}
Six core permission lines plus your MCP read tools. Compare that to the 80+ granular entries I started with. Simpler to read, simpler to maintain, and it actually works for compound commands.
The team template
The project-level .claude/settings.json you commit to git for your team:
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read(*)", "Write(*)", "Edit(*)",
"Bash(*)",
"WebFetch(*)", "WebSearch(*)"
],
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf /*)"
]
}
}
No MCP tools (not everyone uses them). No personal integrations. Just the development essentials. Team members extend this with their personal ~/.claude/settings.json for integrations and personal tools, or a .claude/settings.local.json in the repo (auto-gitignored) for machine-specific overrides.
The accumulation problem
If you don’t set up these files proactively, Claude Code builds your permission list reactively — one approval at a time. Every “Yes, don’t ask again” adds a single entry to your local settings. After a few months of real work, you end up with something like this:
Bash(gcloud run services describe:*)
Bash(gcloud secrets list:*)
Bash(gcloud config get-value:*)
Bash(gcloud sql instances list:*)
Bash(gcloud logging read 'resource.type="cloud_run_revision" AND ...)
Five entries for what should be zero — Bash(*) covers them all.
I found 226 one-off entries accumulated in a project settings file. Specific psql paths, specific wrangler commands with API tokens embedded, specific gcloud subcommands with full query strings. Each one was a moment of interrupted flow that could have been prevented.
The fix took five minutes: replace 226 fragmented entries with Bash(*) and three deny rules. The gist templates give you that five-minute fix without the months of accumulation.
When to revisit
Your permission configuration is not a set-and-forget file. Revisit it when:
- You add a new MCP server — its tools need explicit allow entries
- You join a new project — check if it has a
.claude/settings.jsonyou should extend - You want to gate a specific command — add it to your deny list
- Claude Code updates — new permission modes or syntax may improve your options
The goal is not to eliminate all prompts. The goal is to eliminate the ones that interrupt flow without adding safety. The dangerous operations should still make you pause and think. Everything else should flow.
The full configuration templates are available as a GitHub gist. Clone, customize, and stop getting interrupted.