◉CLAUDE LABJP
●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…
Articles/Claude Code
⟐ Claude Code/2026-10-24Advanced

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.

Claude Code274permissions13settings.local.jsonsandbox10operations31

✦ Premium Article

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, sys
 
READONLY = {"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 0
 
if __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.

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-08-04
Tightening Filesystem Isolation Separately from the Network — Collect the Paths, Then Squeeze the Write Surface
Claude Code v2.1.216 lets you control filesystem isolation independently from network isolation. Before tightening anything, I traced what a real job actually touches, split reads from writes, and measured how stable the path set is across repeated runs. The numbers changed how I wrote the allowlist.
⟐ Claude Code2026-10-19
If You Wrote MCP Allow Rules as Just `mcp__servername` — Three Lines to Recheck After v2.1.299
Claude Code v2.1.299 changed MCP allow rules to match only the server they name. Here is how to audit older entries in settings.json, with a short Python script that does it.
⟐ Claude Code2026-10-18
I Added the allow Rule and the Prompt Still Appears — Reading Six Layers from /permissions
When an allow rule is in your settings and the same approval prompt keeps coming back, something else decided first. Starting from what /permissions shows, here are six layers you can verify against the official permissions docs: ask and deny precedence, where settings.local.json is read, Cowork's shell tool, and more.