●2.1.309 — Sandbox and permission checks are tighter, and hooks and PowerShell on Windows are fixed (10/22)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 37 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.309 — Sandbox and permission checks are tighter, and hooks and PowerShell on Windows are fixed (10/22)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 37 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…
The Settings File I Kept Saying Yes To Had a Rule That Let Any Python Through — Auditing Allow Rules After the 2.1.309 Changelog
Claude Code 2.1.309 is full of permission-check and sandbox fixes. Here is a script that audits the allow rules in settings.local.json, and how to fix a prefix wildcard on an interpreter.
On a Saturday evening I was reading the 2.1.309 entries in the Claude Code changelog, and I stopped.
The permission-check fixes kept coming. Git compound commands with a cd in front, Edit(path) deny rules versus shell redirects, a check that trusted a variable it should not have. Every one of them was the same kind of bug: what the person who wrote the rule expected to pass, and what actually passed, had drifted apart.
That made me wonder about my own rules. The .claude/settings.local.json in my site work folder is a file I have clicked "don't ask again" into for months, through scheduled runs. I could not remember the last time I read it properly.
It was 429 bytes
The file was tiny. Six entries under permissions.allow, readable by eye in a minute. But reading alone would not let me explain what was wrong to anyone else, so I wrote a classifier first.
Allow rules only become visible when you count what each one lets through.
The audit script
The official docs say a Bash rule is matched as written up to the first *. Bash(git log *) allows only git log; Bash(git *) allows every git command. The script uses that property to sort rules into severity levels.
#!/usr/bin/env python3"""Audit permissions.allow in settings(.local).json and sort by risk."""import json, re, sysREADONLY = {"ls", "cat", "echo", "pwd", "head", "tail", "grep", "wc", "which", "diff", "stat", "du"}INTERPRETERS = {"python", "python3", "node", "bash", "sh", "zsh", "ruby", "perl", "deno", "bun", "npx", "uvx", "pwsh", "powershell"}def classify(rule: str): m = re.fullmatch(r"(\w+)\((.*)\)", rule, re.S) if not m: return ("tool-wide", "no parentheses: the whole tool is allowed") tool, body = m.groups() if tool != "Bash": return ("other", f"{tool} rule (out of scope for this script)") if "*" not in body: return ("exact", "exact match: as intended") head = body.split("*", 1)[0] # everything before the first * is matched as written words = head.split() prog = words[0] if words else "" if body.strip() == "*": return ("critical", "same as Bash(*): every command is allowed") if prog == "": return ("critical", "leading *: the program name does not constrain anything") if prog.rsplit("/", 1)[-1] in INTERPRETERS: return ("critical", f"prefix wildcard on interpreter {prog}: anything after * can be code") if len(words) < 2 and not head.endswith(" "): return ("warn", f"wildcard in the middle of a word, like {prog}*") if len(words) < 2: if prog in READONLY: return ("ok", f"{prog} is in the built-in read-only set") return ("warn", f"every subcommand of {prog} is allowed (put the * after the subcommand)") return ("ok", "constrained by program + subcommand")ORDER = {"critical": 0, "warn": 1, "tool-wide": 2, "other": 3, "exact": 4, "ok": 5}def main(path): allow = json.load(open(path)).get("permissions", {}).get("allow", []) rows = sorted(((classify(r), r) for r in allow), key=lambda x: ORDER[x[0][0]]) for (level, why), rule in rows: print(f"{level:9} {rule[:70]!r:74} {why}") bad = sum(1 for (lv, _), _r in rows if lv in ("critical", "warn", "tool-wide")) print(f"-- {bad} of {len(allow)} rules need review") return 1 if bad else 0if __name__ == "__main__": sys.exit(main(sys.argv[1]))
Two choices are worth explaining. The exit code is 0 or 1 so the script can sit in front of a scheduled run. And READONLY is an exception because my first version flagged Bash(ls *), which made the output tiring to read; the commands Claude Code treats as read-only pass without any rule anyway.
✦
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
✦You can open your own settings.local.json and spot, within seconds, the one allow rule that lets arbitrary code through
✦You can replace a prefix wildcard on python3 or node with a pinned script plus a write-deny rule, keeping the convenience while closing the hole
✦You can read a release's changelog and decide in five minutes whether it is a day to re-read your allow list, without reading every line
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.
This is what python3 -I audit_allow.py printed on my own file (the script ran with Japanese messages on my machine; I have translated them here and trimmed long lines).
critical "Bash(python3 -I -c ' *)" prefix wildcard on interpreter python3: anything after * can be codetool-wide 'mcp__scheduled-tasks__list_scheduled_tasks' no parentheses: the whole tool is allowedexact 'Bash(git -C claudelab.net remote -v)' exact match: as intendedexact 'Bash(sort -k3)' exact match: as intendedexact "Bash(sort -t' ' -k2,2n -k1,1n)" exact match: as intendedexact "Bash(python3 -I -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(" exact match: as intended-- 2 of 6 rules need review
Four of the six (about 67%) were exact matches of a single command I had approved, which is the shape I want. Two (about 33%) needed review.
The problem is line one. Anything that begins with python3 -I -c ' passes. -I isolates the import search path; it does not restrict what the code does. The impression that "it has an isolation flag, so it must be safe" is what let me overlook it.
I do not remember writing it by hand, so I believe it was saved through "don't ask again". According to the docs, a Bash approval is stored in .claude/settings.local.json and applies to every later session in that repository. One click becomes a permission with no expiry.
There was a counterintuitive second finding: the rule is not only too broad, it is also brittle on the narrow side. Write python3 -c or /usr/bin/python3 -I -c and the rule no longer matches, so you get prompted anyway. I had kept the risk and gotten only half the convenience.
The fix: drop the wildcard and pin a script
What I wanted was small: read JSON from stdin and count items. So I moved the logic into a file.
#!/usr/bin/env python3"""Read JSON from stdin and print the length of data[] and has_more on one line."""import json, sysd = json.load(sys.stdin)print(len(d.get("data", [])), d.get("has_more"))
Then the permission rules target only that script.
The reasoning: with an exact-match rule, the only thing that passes is "run this file". But that is meaningless if the file itself can be rewritten, so the deny rules block edits. Per the docs, rules are evaluated deny, then ask, then allow, so a deny on that path holds regardless of what else matches.
The convenience survives: no prompt, and the content is fixed. The cost is that when I want the script to change, I edit it by hand. As an indie developer I will take that trade.
The docs are also clear that a deny rule stops one way of writing a command and is not a security boundary. If you need writes blocked for certain, enforce it in the sandbox. 2.1.309 also fixed sandbox denyWrite patterns not applying when the folder was reached through a symlink.
Deciding whether it is an audit day
I do not read the whole changelog. I look for three kinds of words.
Words in the changelog
What to check in your settings
permission check / compound command
How allow rules treat compound commands (cd, &&). Are any prefixes too broad?
deny rule / redirect
Are you assuming a deny path is also protected against redirects?
sandbox / denyWrite / excluded
Is the list of commands excluded from the sandbox still only what you need?
2.1.309 matched all three, so I made it an audit day. 2.1.307 added a claude_code.safety_event OpenTelemetry event for each tool call that a rule, auto mode, a hook or the sandbox stopped or sent back. I have not enabled it yet and left it in my notes as the next step.
Reproducing it on your own machine
I recommend this order, and I always take a copy first.
Copy .claude/settings.local.json somewhere else, so you can diff after the fix
Run python3 -I audit_allow.py .claude/settings.local.json and pick out only the critical and warn lines
For each critical line, try to recall from your recent work log what it was saved for. If you cannot, deleting it is fine; you can approve again next time it matters
Move the same job into a pinned script, add the exact-match rule and the deny rules, and run the audit again until it exits with 0
If the folder feeds a scheduled run, do one manual run of the real job after step 4 and confirm it passes without a prompt. That avoids the morning where the run sits stopped.
Pitfalls
Exact match is not automatically safe: the saved rule is the approved command verbatim, so credentials or absolute paths can sit inside it. Read them during the audit.
tool-wide means review, not delete: a read-only MCP listing tool like the one here is fine to allow whole. Think about narrowing only for tools that write.
The classifier is an approximation: it only looks before the first * and does not replicate Claude Code's matcher. The final judgment is the docs plus whether a prompt actually appears.
Next step
Today's work was three moves: open the file, classify it with the script, and replace the dangerous line with a pinned script. With only six rules it fit in one evening.
In an indie developer's work folder, approvals pile up without announcing themselves. It helped me to remember that a changelog day is also a day to open my own settings 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.