●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then
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.
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.
Cost
After 2.1.209
Whose problem
Compiling rule strings into matchers (per turn)
Cached — gone
Claude Code
Assembling the MCP tool pool (per round)
Cached — up to 7x faster
Claude Code
The rule count itself (how much to match against)
Still grows linearly
You
Shadowed and contradictory rules
Still there
You
How many MCP servers you attach
Still grows
You
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 jsonimport reimport sysfrom collections import Counter, defaultdictfrom pathlib import PathBUCKETS = ("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.
Duplicates are easy. Shadowing is where the volume actually comes from: you deny Bash(rm -rf:*), and somewhere else you also deny Bash(rm -rf /tmp/cache:*). The second one is swallowed by the first. It exists only to be matched against.
Deciding glob containment rigorously is hard, so I settled for a practical approximation: within one bucket and one tool, check whether one pattern's literal prefix contains another's.
#!/usr/bin/env python3"""find_shadowed.py — detect rules covered by a broader rule in the same bucket.Usage: python3 find_shadowed.py ~/.claude/settings.json"""import jsonimport reimport sysfrom collections import defaultdictfrom pathlib import PathRULE_RE = re.compile(r"^(?P<tool>[A-Za-z_][\w-]*)(?:\((?P<arg>.*)\))?$")def literal_prefix(pattern: str) -> str: """Return the literal part before any glob. 'rm -rf:*' -> 'rm -rf:'""" out = [] for ch in pattern: if ch in "*?[": break out.append(ch) return "".join(out)def covers(broad: str, narrow: str) -> bool: """True if broad plausibly swallows narrow. Deliberately conservative.""" if broad == narrow: return False if not broad.endswith("*"): return False # no trailing wildcard: do not attempt a verdict bp = literal_prefix(broad) np = literal_prefix(narrow) if not bp: return True # "*" swallows everything return np.startswith(bp)def main(path: str): data = json.loads(Path(path).expanduser().read_text(encoding="utf-8")) perms = data.get("permissions", {}) found = 0 for bucket, rules in perms.items(): groups = defaultdict(list) for rule in rules: m = RULE_RE.match(rule.strip()) if not m or m.group("arg") is None: continue groups[m.group("tool")].append((rule, m.group("arg"))) for tool, items in groups.items(): for broad_rule, broad_arg in items: for narrow_rule, narrow_arg in items: if covers(broad_arg, narrow_arg): found += 1 print(f"[{bucket}] {narrow_rule}") print(f" ^ covered by {broad_rule}") print(f"\nShadowing candidates: {found}") print("Approximate. Review every hit by eye before deleting anything.") return foundif __name__ == "__main__": sys.exit(0 if main(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/settings.json") == 0 else 0)
The important choice is how conservative covers() is. Broad rules without a trailing wildcard are skipped entirely, and containment is judged on literal prefixes only. Chasing full glob semantics here would eventually mislabel a rule you needed and talk you into deleting it. Missing a candidate is fine; a false positive is not.
Of my 141 deny rules, 34 came back as candidates. After reviewing each one, I removed 28. The remaining 6 were intentional pairs like Bash(git push:*) in ask alongside Bash(git push --force:*) in deny. Different buckets, so the detector never flagged them — and they earn their keep.
Measure the per-turn overhead
You want to know whether the trim actually did anything. When the only variable is the ruleset, running a minimal prompt through claude -p a handful of times is enough.
#!/usr/bin/env bash# bench_turn.sh — swap rulesets and compare turn time.## ./bench_turn.sh before.json after.json 10#set -euo pipefailSETTINGS_A="${1:?path to the baseline settings.json}"SETTINGS_B="${2:?path to the trimmed settings.json}"RUNS="${3:-10}"PROMPT='Reply with exactly: ok'run_set() { local label="$1" settings="$2" local times=() for i in $(seq 1 "$RUNS"); do local start end start=$(date +%s%N) CLAUDE_CONFIG_DIR="$(mktemp -d)" \ claude -p "$PROMPT" \ --settings "$settings" \ --model haiku \ > /dev/null 2>&1 || true end=$(date +%s%N) times+=( $(( (end - start) / 1000000 )) ) done # Compare medians so the first warm-up run does not drag the average local sorted median sorted=$(printf '%s\n' "${times[@]}" | sort -n) median=$(printf '%s\n' "$sorted" | awk '{a[NR]=$1} END {print (NR%2) ? a[(NR+1)/2] : int((a[NR/2]+a[NR/2+1])/2)}') printf '%-10s median=%5s ms min=%5s ms max=%5s ms n=%s\n' \ "$label" "$median" "$(printf '%s\n' "$sorted" | head -1)" \ "$(printf '%s\n' "$sorted" | tail -1)" "$RUNS" echo "$median"}echo "=== Rule counts ==="for f in "$SETTINGS_A" "$SETTINGS_B"; do n=$(python3 -c "import json,sysp=json.load(open('$f')).get('permissions',{})print(sum(len(v) for v in p.values()))") printf ' %-40s %s rules\n' "$(basename "$f")" "$n"doneechoMED_A=$(run_set "before" "$SETTINGS_A" | tail -1)MED_B=$(run_set "after" "$SETTINGS_B" | tail -1)echoawk -v a="$MED_A" -v b="$MED_B" 'BEGIN { d = a - b printf "delta: %+d ms (%.1f%%)\n", -d, (d / a) * 100}'
Two details carry this measurement. Pointing CLAUDE_CONFIG_DIR at a throwaway directory each run keeps prior session state out of the numbers, and taking the median keeps the first cold run from skewing the result.
The --model haiku choice is deliberate. What you are timing is session assembly, not inference. The faster and shorter the response, the larger the overhead looms as a fraction of the total — which is what makes it visible.
Going from 226 rules to 154 moved my median by roughly 90ms. That is a sensible size for a post-2.1.209 world, where the caching already did the heavy lifting. Read it the other way, though: if you are still on an older build with hundreds of rules, this is where your seconds are going. Update first, audit second. That order matters.
Fold enumerations into prefixes
Cutting the count is pointless if it opens a hole. What worked for me was to stop enumerating, block one level broader with a prefix, and reopen only the exceptions I actually use.
The "before" is what you get by appending as you go:
Twelve lines became nine, but the meaning changed more than the count. The "before" only ever covered the five paths I happened to think of, so rm -rf /usr walked straight through. The "after" blocks rm -rf wholesale and names the two I use daily. Fewer lines, and safer.
Three things I was careful about.
First, the allow exceptions are exact matches with no wildcard. Writing Bash(rm -rf ./node_modules:*) would leave room for rm -rf ./node_modules; rm -rf / to ride along. Exceptions should be as narrow as you can stand.
Second, Read(./.env*) sweeps up .env.example too, so it needs an explicit escape hatch. Broad blocks always produce collateral like this. This is the pitfall that caught me: I folded the rules on a pipeline already in production, and that night's unattended run stalled trying to read .env.example. The workaround is unexciting — block broadly, run a normal day's work by hand once, and promote whatever stalls into allow. Do not let an unattended schedule be the thing that discovers your collateral.
Third, I ran find_shadowed.pyagain after the rewrite. Folding rules together can quietly create new shadowing of its own.
If the weight persists after trimming rules, the tool pool is the next suspect.
In 2.1.209, tool pool assembly in print/SDK sessions is cached, and the payoff scales with tool count — up to 7x. Flip that around and it says something plain: the more tools you expose, the more there was to pay for. Caching does not delete the cost; it folds it into a single occurrence.
Start by counting what you actually have open.
#!/usr/bin/env bash# count_mcp_tools.sh — count connected MCP servers and their tools.set -euo pipefailecho "=== Servers declared in config ==="for f in "$HOME/.claude.json" ".mcp.json"; do [ -f "$f" ] || continue python3 - "$f" << 'PY'import json, sysfrom pathlib import Pathp = Path(sys.argv[1])data = json.loads(p.read_text(encoding="utf-8"))servers = data.get("mcpServers") or {}# ~/.claude.json can nest servers per projectfor proj, cfg in (data.get("projects") or {}).items(): for name in (cfg.get("mcpServers") or {}): servers.setdefault(f"{name} ({proj})", {})print(f" {p}: {len(servers)} servers")for name in sorted(servers): print(f" - {name}")PYdoneechoecho "=== Tools actually open ==="claude mcp list 2>/dev/null | sed 's/^/ /' || echo " (could not read claude mcp list)"
In my case every server lived in the user config regardless of project, so a session for writing articles was dragging a deployment server along for the ride. Assembling tools I will never call is straightforwardly wasted work.
The fix was unglamorous: keep only the servers you use in every project at the user level, and move the rest into a per-project .mcp.json.
Run audit_permissions.py once and look at the number. That is genuinely enough.
My guess was 60. The answer was 226. That gap was the shape of my design blind spot. Whether to cut anything is a decision you can make after you see the number — sitting on an unexamined "probably fine" is the part that costs you.
Settings added for safety, quietly costing speed. I doubt permissions are the only place that pattern hides. I am still partway through my own audit, but counting alone changed how I look at the file.
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.