Cutting Claude Code's Permission Prompts Without Going Reckless

Approving the same twenty commands is misery; turning permissions off is dangerous. A well-scoped allowlist is the middle path — here's how to build one.

The fastest way to hate using Claude Code is to approve the same twenty commands over and over. "Run the tests?" Yes. "Run the tests again?" Yes. "Stage these files?" Yes, obviously, for the fiftieth time. Permission fatigue is real, and it pushes people toward the nuclear option — turning permissions off entirely — which trades a small annoyance for a genuine risk. There is a better middle path: a well-scoped allowlist.

How permissions actually work

Claude Code sorts tool calls into tiers. Read-only actions (reading files, grepping) run without asking. Anything that can change your machine or the world — shell commands, file edits — requires approval, unless a rule says otherwise.

Those rules live in your settings under three buckets: allow, ask, and deny. When a tool call comes in, the buckets are checked with a fixed precedence: deny wins, then ask, then allow. This order does not depend on how specific a rule is or where it sits in the file — a broad deny beats a narrow allow every time. That is a feature: it means you can safely open up a category while still carving out exceptions you never want touched.

The allowlist

Here is the shape of a permissions block in .claude/settings.json:

{
  "permissions": {
    "allow": [
      "Bash(npm run test:*)",
      "Bash(git add:*)",
      "Bash(git commit:*)",
      "Read(src/**)",
      "Edit(src/**)",
      "WebFetch(domain:github.com)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Bash(rm -rf *)"
    ]
  }
}

That config says: run my tests, stage, and commit without asking; read and edit anything under src/; fetch GitHub — but never push, and never force-delete, even if I seem to be asking for it. The result is a session where the routine stuff just flows and the irreversible stuff still stops for a human.

The rule syntax (and its sharp edges)

The syntax looks simple and has a couple of genuinely surprising corners.

Bash patterns match command prefixes. Bash(npm run test:*) allows any npm run test... command. The :* is a suffix wildcard; you can also write it with a space, Bash(git commit *). Two things will bite you:

  • The space before * is load-bearing. Bash(ls *) matches ls -la but not lsof, because the space forces a word boundary. Bash(ls*) with no space matches both. Small character, big difference.
  • Bash rules do not validate arguments. Bash(curl github.com *) looks like "only let curl hit GitHub," but it happily matches curl evil.com too, because the pattern is a prefix match, not a parser. If you want to constrain a destination, do not try to do it with a Bash rule — use the tool built for it.

That tool is WebFetch. WebFetch(domain:github.com) restricts fetches to a domain properly, and WebFetch(domain:*.example.com) covers subdomains. This is the right way to whitelist network access.

Read/Edit patterns use gitignore-style globs, anchored to your project: Read(src/**), Edit(config/**). MCP tools are named mcp__server for a whole server or mcp__server__tool for one tool.

Precedence across files

Settings stack. From strongest to weakest: managed enterprise policy, then CLI flags, then .claude/settings.local.json (your private project settings), then .claude/settings.json (shared with the team), then ~/.claude/settings.json (your user defaults). A deny at any level blocks everything below it — so an org can forbid git push and no local allow can override it. Put team-wide rules in the committed project file; keep personal conveniences in your local or user file.

A safe way to dial down the prompts

You do not have to design the perfect allowlist up front. Let it emerge:

  1. Start in the default mode, approving as you go.
  2. Notice the commands you approve every time without thinking — your test runner, your linter, git add, git commit.
  3. Move exactly those into allow, scoped as narrowly as they deserve.
  4. Add deny rules for the handful of things you never want automated — git push, destructive deletes, anything that talks to production.
  5. Use the /permissions command to see your active rules and what got denied recently, and refine.

Within a session or two, the repetitive approvals disappear and the only prompts left are the ones that genuinely deserve a human glance.

The thing not to do

It is tempting, when the prompts pile up, to reach for bypassPermissions mode or the --dangerously-skip-permissions flag. These skip essentially every check. On your real development machine, that is how an AI ends up writing into .git internals, or running a command you would never have approved, with no undo. The word "dangerously" is in the flag name on purpose.

There is a legitimate use for it — isolated, disposable environments: a Docker container or a throwaway VM where Claude Code cannot damage anything you care about. In that box, skipping permissions is reasonable because the blast radius is contained. On your laptop, it is not.

The whole point of an allowlist is that you should never need the nuclear option. A dozen well-scoped allow rules and a few firm deny rules give you a session that is smooth and safe — which is the combination that lets you actually trust the tool with real work.


Related reading