◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-07-16Advanced

The Permission Rules You Added for Safety Are Taxing Every Turn — Auditing the Ruleset Without Loosening It

Version 2.1.209 fixed the per-turn slowdown from large deny/ask rulesets, but the design debt in your rules is still yours. Here are the audit scripts, a shadowing detector, a turn-timing harness, and how to fold enumerated rules into prefix rules safely.

Claude Code264Permissions7settings.json8MCP54Performance4

✦ Premium Article

I opened the settings.json for the publishing pipeline I run as an indie developer and found 140-plus lines in the deny array.

Every line had a story. A command that scared me during an overnight run. A path I did not want read by accident. A new "just in case" entry every time I connected another MCP server. Six months of that.

And every one of those sessions felt vaguely slow. I never found a cause, so I filed it under "the model is having a day."

The 2.1.209 release notes named it. Sessions with many deny/ask rules were losing seconds on every turn, and the fix was to compile the matchers once and cache them. The settings I had stacked up in the name of caution were quietly costing me speed.

What the fix removed, and what it left on your desk

Worth separating clearly: 2.1.209 removed a runtime recompilation cost. It did not remove your ruleset design debt.

CostAfter 2.1.209Whose problem
Compiling rule strings into matchers (per turn)Cached — goneClaude Code
Assembling the MCP tool pool (per round)Cached — up to 7x fasterClaude Code
The rule count itself (how much to match against)Still grows linearlyYou
Shadowed and contradictory rulesStill thereYou
How many MCP servers you attachStill growsYou

The top two arrive with an update. The bottom three stay until you deal with them.

There is a twist here. Now that the compile cost is gone, the pain that used to signal the problem is gone too. Debt grows fastest when it stops hurting, which is exactly why I wanted to count mine now.

Count first — auditing the ruleset

Deleting rules on a hunch is a bad idea, so I let a script do the counting. It reads your settings files and reports the rule count, duplicates, and distribution per tool.

#!/usr/bin/env python3
"""audit_permissions.py — inventory the permission rules in your settings files.
 
Usage:
    python3 audit_permissions.py ~/.claude/settings.json .claude/settings.json
"""
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
 
BUCKETS = ("allow", "ask", "deny")
 
# "Bash(rm -rf:*)" -> ("Bash", "rm -rf:*") / "Read" -> ("Read", None)
RULE_RE = re.compile(r"^(?P<tool>[A-Za-z_][\w-]*)(?:\((?P<arg>.*)\))?$")
 
 
def parse_rule(rule: str):
    m = RULE_RE.match(rule.strip())
    if not m:
        return None, rule.strip()
    return m.group("tool"), m.group("arg")
 
 
def load(path: Path) -> dict:
    if not path.exists():
        print(f"  skip (not found): {path}")
        return {}
    with path.open(encoding="utf-8") as fh:
        return json.load(fh)
 
 
def audit(paths):
    total = Counter()
    by_tool = defaultdict(lambda: defaultdict(list))
    seen = defaultdict(list)  # rule -> [(bucket, file), ...]
 
    for p in paths:
        data = load(Path(p).expanduser())
        perms = data.get("permissions", {})
        for bucket in BUCKETS:
            for rule in perms.get(bucket, []):
                total[bucket] += 1
                tool, arg = parse_rule(rule)
                by_tool[tool][bucket].append(arg)
                seen[rule].append((bucket, str(p)))
 
    print("=== Totals ===")
    for b in BUCKETS:
        print(f"  {b:5s}: {total[b]:4d}")
    print(f"  total: {sum(total.values())}")
 
    print("\n=== Per tool (most rules first) ===")
    rows = sorted(by_tool.items(), key=lambda kv: -sum(len(v) for v in kv[1].values()))
    for tool, buckets in rows:
        counts = " ".join(f"{b}={len(buckets[b])}" for b in BUCKETS if buckets[b])
        print(f"  {tool:28s} {counts}")
 
    print("\n=== Exact duplicates and bucket conflicts ===")
    dup = 0
    for rule, hits in seen.items():
        if len(hits) < 2:
            continue
        dup += 1
        buckets = {b for b, _ in hits}
        mark = "CONFLICT" if len(buckets) > 1 else "dup"
        print(f"  [{mark}] {rule}")
        for b, f in hits:
            print(f"        {b:5s} <- {f}")
    if dup == 0:
        print("  none")
    return sum(total.values())
 
 
if __name__ == "__main__":
    args = sys.argv[1:] or ["~/.claude/settings.json", ".claude/settings.json"]
    n = audit(args)
    sys.exit(0 if n else 1)

On my machine it printed 141 deny, 22 ask, and 63 allow — 226 rules total. Nine exact duplicates, and two conflicts where a project setting put something in allow while my user setting denied it. Both conflicts resolved to the safe side, which is precisely why I had never noticed them.

Before running it I would have guessed around 60. Being off by an order of magnitude is a useful place to start.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦An audit script that counts, dedupes, and surfaces bucket conflicts across your settings files
✦A turn-timing harness using claude -p, with the two details that make the comparison trustworthy
✦How to fold enumerated deny rules into prefix rules without opening a hole
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-10-13
The Same Approval Prompt for 'cd, git add, git commit' Every Time — Following It to a Design Choice, Not a Missing Setting
With a compound command like cd /path && git add && git commit, Claude Code keeps asking for approval even with allow rules in place. Reading the official permissions page next to a GitHub report shows that cd combined with git prompts by design. Here is how to tell, in what order to check, and four ways to cut the prompts.
⟐ Claude Code2026-09-05
My deny rule was watching a filename, not the routes that reach it
One line in deny let several other spellings of the same file walk right past. Here is the small matrix I build to list every route to a protected file, and how I re-check it on upgrade day.
⟐ Claude Code2026-09-04
The ~/.claude.json Rollback Is Fixed in v2.1.259. Putting Back What It Erased Is Still Your Job
Concurrent sessions used to silently roll back each other's ~/.claude.json changes. v2.1.259 fixed that, but nothing restores the trust settings and MCP servers you already lost. Here is how I find and rebuild them, with a small key-path diff script.