Claude Code · Permissions

I had 285 Claude Code permission rules. Only 41 did anything.

A tall stack of 285 narrow Claude Code permission rules collapsing down to 41, with one broad wildcard at the top making the rest redundant - consolidate-permission-prompts
TL;DR
  • Every time you click "allow always" in Claude Code, a rule gets appended to permissions.allow. It never gets cleaned up.
  • Mine had grown to 285 rules. When I actually looked, only 41 mattered - roughly six out of every seven were dead weight.
  • The worst offender: 150+ per-domain WebFetch rules, one per site I'd ever fetched, all made pointless the moment a single WebFetch(*) exists.
  • I wrote a skill, consolidate-permission-prompts, that scans the allowlist, marks every rule already covered by a broader one, and removes the junk - without ever narrowing what's allowed.
  • It's the sibling of the built-in /fewer-permission-prompts. That one stops future prompts. This one clears the mess the past ones left behind.

I clicked "allow always" a lot. Two years of it. Every new site Claude Code fetched, every git subcommand, every one-off script - "yes, don't ask again." That's the right instinct. Approving things once so you're not nagged forever is the whole point of the allowlist.

What nobody tells you is that the list only ever grows. There's no janitor. Two years in, I opened my settings.local.json out of curiosity and counted 285 rules in permissions.allow. That's when I got curious about how many of them were actually doing something.

The answer was 41. The other 244 were dead weight - rules that could vanish tomorrow and nothing I do would change. Here's how a list rots like that, and the skill I wrote to clean it up safely.

The 150 WebFetch rules that stopped mattering years ago

Here's the clearest example, and probably the one you'll recognize in your own config.

Every time Claude Code fetches a URL from a domain it hasn't seen, it asks. You approve it, and it writes a per-domain rule. Do that for two years and you get a graveyard:

"WebFetch(domain:example.com)",
"WebFetch(domain:docs.python.org)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:news.ycombinator.com)",
"WebFetch(domain:some-blog-i-read-once.dev)",
... 150 more just like it ...

One rule per site I ever fetched. Individually approved, individually stored, all sitting there forever. And then at some point I got tired of approving each new domain and added the broad one:

"WebFetch(*)"

That single line allows fetching any URL. Which means the moment it exists, all 150 of the per-domain rules above it are dead weight. Removing every one of them changes nothing about what Claude Code can do, because the wildcard already covers all of them and more.

A narrow rule sitting under a broad rule that already covers it does nothing. It's just noise you scroll past. Delete all 150 and your permissions are byte-for-byte identical in effect.

That's the pattern the whole skill is built around: find the rules that are already covered by a broader one, and get rid of them. It's far more common than you'd expect, because "allow always" never checks whether a broader rule already handles the thing you're approving.

Three ways the list rots

WebFetch is the vivid one, but the allowlist collects dead weight in three distinct shapes. The skill classifies every rule into one of them.

1. Redundant - already covered by something broader

The WebFetch case. Also things like a Bash(git commit:*) rule when Bash(git *) already exists somewhere - the specific one is covered by the general one. Removing it is provably safe, because nothing that was allowed stops being allowed.

2. Junk - one-offs that will never match again

This is the stuff that got captured as a rule but describes a single moment in time. A heredoc commit message you'll never type verbatim again. A mkdir to one exact absolute path. And my favourite category, shell control-flow fragments that got split into their own rules when a loop was approved:

"Bash(done)",
"Bash(do ls)",
"Bash(while read f)"

None of those will ever be issued as a standalone command again. They're debris. The skill is deliberately conservative here - a real long command that could plausibly re-run stays, and it never touches a deliberate wildcard like Bash(for *).

3. Collapsible families - narrow rules that could be one wildcard

Sometimes you've approved five subcommands of the same tool separately:

"Bash(alembic init:*)",
"Bash(alembic revision:*)",
"Bash(alembic upgrade:*)",
"Bash(alembic downgrade:*)",
"Bash(alembic current:*)"

Those could collapse into a single Bash(alembic *). But here's the important difference: that widens what's allowed. It would now permit alembic subcommands you never approved. So the skill treats consolidation as a separate, explicit opt-in - never bundled with the safe cleanup, and flagged loudly when the command is something dangerous like an interpreter or ssh.

It's the sibling of /fewer-permission-prompts

Claude Code ships with a built-in skill called /fewer-permission-prompts. It's good. It reads your recent transcripts, notices the commands you keep approving, and adds allow-rules so you stop getting asked. It works on the future - fewer prompts from here on.

The problem is that it only ever adds. Run it every few weeks for a year and you're back to a bloated list, because the additions pile up and nothing removes the ones that later became redundant.

consolidate-permission-prompts is the other half. It works on the past - the accumulated rules you already have. One prevents new clutter, the other clears the old. They're complementary, and I run both: /fewer-permission-prompts to stop the nagging, then consolidate-permission-prompts every so often to sweep up.

/fewer-permission-prompts stops you clicking "allow always" so often. consolidate-permission-prompts cleans up after all the times you already did. You keep clicking accept - this is the janitor for what that leaves behind.

Why mine collapsed so hard: scopes stack

285 down to 41 sounds extreme until you know why. Claude Code merges permission rules from three files at runtime:

My user-level file had broad wildcards in it - the wide ones I'd added over time. Every project's local file, meanwhile, had accumulated its own pile of narrow rules from day-to-day "allow always" clicks. And a narrow local rule is redundant the second an equal-or-broader user rule already covers it, because the user rule applies everywhere anyway.

So the bulk of those 244 removals were narrow local rules already handled by a wildcard one scope up. This is exactly where you have to be careful, and it's the one thing the analyzer is strict about.

The safety rule that makes it trustworthy

The whole thing only works if it never quietly takes away access. So coverage is scope-aware. A rule is marked redundant only when the covering rule has equal-or-broader reach. A user rule can cover a local one, because user applies to every project. The reverse is never true - a rule in one project's local file can't cover a user rule, so the analyzer won't touch the user rule on account of it. Getting that backwards would silently narrow your permissions, which is the one outcome that's genuinely bad.

A few more lines I drew hard:

When it's unsure, it keeps the rule. A borderline rule staying costs you one line of noise. Wrongly dropping one could cost you a permission you rely on. That trade is never close.

How to run it

Drop it into your skills directory and invoke it, or run the analyzer directly to just look:

# install (user-level, all projects)
git clone https://github.com/MarcinDudekDev/consolidate-permission-prompts.git \
  ~/.claude/skills/consolidate-permission-prompts

# or just look, read-only, no changes
python3 scripts/analyze.py \
  --user   ~/.claude/settings.json \
  --project .claude/settings.json \
  --local   .claude/settings.local.json \
  --dry-run --emit ./preview

The --emit flag writes a before/after file per scope so you can diff the exact result before anything gets applied. When you run it as a skill in Claude Code, it walks the full flow: analyze, show you a per-scope summary, confirm the safe cleanup separately from any scope-widening, back up each file, apply, and print the exact restore command. Two-tier confirmation, because "remove dead weight" and "widen what's allowed" are different decisions and shouldn't share a yes.

Here's roughly what my summary looked like (numbers real, rule contents kept generic):

ScopeBeforeRedundantJunkKept
usera fewa fewbroad wildcards
local285most of themthe fragments41

The bulk was redundant, not junk - narrow local rules already covered one scope up. That's the shape of a list that grew under a broad wildcard, and I'd bet a lot of long-running setups look the same once you actually count.

Built with a frozen safety net

A tool that edits your permission config has exactly one job it can't get wrong: don't silently change what's allowed. So I built it against a frozen adversarial test suite - 96 cases that lock the coverage logic, the scope-reach rules, and the junk classification - plus an independent cross-model review pass. Every "this is safe to drop" claim is pinned by a named test. If a change breaks the promise, the suite goes red. It's MIT-licensed on GitHub, tests included.

Takeaway

Keep clicking "allow always" - it's the right call in the moment. Just know the list underneath never cleans itself, and after a couple of years most of it is covered by a handful of broad rules you added later. Mine was 285 lines pretending to matter, and 41 that actually did. Count yours sometime. If you've got a WebFetch(*) in there, I already know how the per-domain section looks.

I'll keep sharing the small tools I build around Claude Code. The useful ones are usually the boring cleanup jobs nobody bothers to automate.