A Synthesis Engineering Craft

Stop asking me: configuring Claude Code permissions for uninterrupted flow

Originally published on rajiv.com

Updated April 13, 2026: Added the dontAsk permission mode (silently denies unmatched tools instead of prompting), expanded the permission modes table to cover all six options, and added a section on settings drift — how “don’t ask again” approvals silently undo your clean configuration. The gist templates are updated to match.

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:

ScopeFile locationWho it affectsShared?
User~/.claude/settings.jsonYou, everywhereNo
Project.claude/settings.json in repoAll collaboratorsYes (git)
Local.claude/settings.local.json in repoYou, this repo onlyNo (gitignored)
ManagedSystem-levelAll users on machineYes (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:

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": "dontAsk",
    "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 prompts.

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": "dontAsk" 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.

Claude Code has six permission modes. Most guides only mention two or three:

ModeBehaviorWhat happens to unmatched tools
defaultStandard promptingPrompts for approval
planAnalysis only, no executionSame as default
acceptEditsAuto-approves file edits and common filesystem commandsPrompts for approval
dontAskWhitelist-only — runs allowed tools, rejects everything elseSilently denied
autoAuto-approves with background safety classifierAuto-approved (research preview)
bypassPermissionsSkips all permission checksAuto-approved (sandboxed environments only)

The critical difference is what happens when a tool doesn’t match any rule in your allow or deny list. With acceptEdits, unmatched tools trigger a permission prompt — you still get interrupted. With dontAsk, unmatched tools are silently skipped — no interruption at all.

For practitioners who maintain an explicit allow list, dontAsk is the right choice. Combined with Bash(*), every bash command runs without prompting. Combined with your MCP read entries, every read operation runs without prompting. Everything you use flows uninterrupted. And tools you deliberately omitted from the allow list — like Slack message sending — are quietly blocked without a modal dialog asking you to approve something you don’t want happening in the first place.

The result: zero permission prompts. Allowed tools execute immediately, omitted tools are silently skipped, and denied tools are explicitly blocked. No interruptions from any direction.

acceptEdits is still reasonable if you prefer being prompted for tools you haven’t explicitly configured rather than having them silently skipped. Choose based on which failure mode you prefer: an interruption you can approve, or a silent skip you notice in the output.

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": "dontAsk",
    "allow": [
      "Read(*)", "Write(*)", "Edit(*)",
      "Bash(*)",
      "WebFetch(*)", "WebSearch(*)",

      "mcp__slack__slack_read_channel",
      "mcp__slack__slack_read_thread",
      "mcp__slack__slack_read_user_profile",
      "mcp__slack__slack_read_canvas",
      "mcp__slack__slack_search_channels",
      "mcp__slack__slack_search_users",
      "mcp__slack__slack_search_public",
      "mcp__slack__slack_search_public_and_private",
      "mcp__plugin_playwright_playwright__*",
      "mcp__claude_ai_Hugging_Face__*"
    ],
    "deny": [
      "Bash(rm -rf /)",
      "Bash(rm -rf ~)",
      "Bash(rm -rf /*)"
    ]
  }
}

Six core permission lines plus your MCP read tools. Compare that to the 130+ granular entries I started with. Simpler to read, simpler to maintain, and it actually works for compound commands.

Notice the MCP wildcards: mcp__plugin_playwright_playwright__* covers all Playwright operations, and mcp__claude_ai_Hugging_Face__* covers all Hugging Face tools. Use this pattern for any integration where you trust all operations. For integrations where you want read-only access (like Slack), enumerate the specific read tools and omit the write tools.

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 /*)"
    ]
  }
}

Note acceptEdits here, not dontAsk. For team-shared settings, acceptEdits is the safer default — new team members get prompted for tools not in the allow list rather than having them silently fail. Individual practitioners who want the dontAsk behavior set it in their personal ~/.claude/settings.json, which takes precedence for their own sessions.

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.

Settings drift — the silent regression

Here is the part I didn’t anticipate when I wrote the first version of this article: the accumulation problem isn’t a one-time fix. It’s a recurring failure mode.

Two weeks after switching to Bash(*) with a clean six-line allow list, I opened my settings.json and found 130+ granular entries. Bash(git *), Bash(grep *), Bash(find *), Bash(curl *) — the exact pattern I had replaced. The prompts were back, and I was approving commands again without noticing the drift.

What happened: every time Claude Code encounters a command that doesn’t match an allow rule (even briefly, during a session restart or settings reload), it offers a “don’t ask again” option. Clicking that adds a granular entry. Over days or weeks, these granular entries accumulate alongside — or replace — the Bash(*) rule. Your clean configuration silently reverts to the fragmented one.

The defense is simple but requires discipline:

  1. Audit periodically. Open ~/.claude/settings.json once a week. If the allow list is longer than ~15 entries, it has drifted.
  2. Never click “don’t ask again.” If you’re getting a prompt, the allow list is wrong. Fix the settings file directly rather than letting one-off approvals accumulate.
  3. Version control your settings. Keep a canonical copy in your dotfiles repo. When drift happens, restore from the canonical version rather than manually pruning.

The underlying design tension: Claude Code optimizes for safety-by-default, which means it errs toward prompting. Your settings file optimizes for flow, which means it errs toward allowing. Every “don’t ask again” click is the safety default winning a small battle against your flow configuration. The fix is to stop fighting at the prompt level and maintain the configuration at the file level.

When to revisit

Your permission configuration is not a set-and-forget file. Revisit it when:

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.

synthesis codingclaude codedeveloper experienceAI-assisted developmentconfigurationproductivity