●OUTPUT — v2.1.237 ships a built-in Concise output style that leads with results and skips preamble, without cutting any corners on the work itself. Pick it under Output style in /config●CACHE — v2.1.237 fixes prompt caching for sessions running through an LLM gateway or a custom base URL. If you work behind a company proxy, your effective per-token cost just changed●CONFIG — v2.1.236 adds ANTHROPIC_DEFAULT_MODEL to set which model new sessions start on. A /model pick still overrides it and persists across restarts, unlike ANTHROPIC_MODEL●NOTIFY — With notify_when_idle in v2.1.236, one Claude Code session can ask another on the same machine for a single heads-up when it next goes idle. Opt-in, one-shot, no polling●SECURITY — On macOS, wildcard read-deny rules now win inside allowed read regions, cover the contents of matched directories, and can no longer be sidestepped by renaming the file●PRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing starting September 1. Ten days to go●OUTPUT — v2.1.237 ships a built-in Concise output style that leads with results and skips preamble, without cutting any corners on the work itself. Pick it under Output style in /config●CACHE — v2.1.237 fixes prompt caching for sessions running through an LLM gateway or a custom base URL. If you work behind a company proxy, your effective per-token cost just changed●CONFIG — v2.1.236 adds ANTHROPIC_DEFAULT_MODEL to set which model new sessions start on. A /model pick still overrides it and persists across restarts, unlike ANTHROPIC_MODEL●NOTIFY — With notify_when_idle in v2.1.236, one Claude Code session can ask another on the same machine for a single heads-up when it next goes idle. Opt-in, one-shot, no polling●SECURITY — On macOS, wildcard read-deny rules now win inside allowed read regions, cover the contents of matched directories, and can no longer be sidestepped by renaming the file●PRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing starting September 1. Ten days to go
My read-deny rules never reached inside the folder I had allowed
An audit of how far permissions.deny Read rules actually reach in a working tree where secrets and source code live side by side, with a script you can run today.
I was doing a cleanup pass over my working tree when I stopped.
.env was in the deny list. So was the whole secrets/ directory. And yet tools/backup/.env.bak matched nothing at all.
It was a backup I had taken six months earlier, before swapping some configuration around. The contents were untouched, and the keys inside it still worked.
The short version
permissions.deny Read rules block whatever matches the patterns you wrote. That sounds obvious, but here is what it means for a real working tree: safety is not measured by how many rules you wrote, it is measured by how many secrets are still uncovered.
In my setup, a naive two-line configuration left eight files completely untouched by any rule. Rewriting it as ten lines brought that number to zero.
What follows is how I counted, and what I decided afterwards.
Secrets and source living in the same tree
Clean repository separation does not always survive contact with how an indie developer actually works.
In my case, iOS and Android apps sit alongside several sites under a single synced folder. The app side holds the signing keys used for Google Play submissions and google-services.json; the site side holds a .dev.vars for Cloudflare. Keeping them next to the build configuration and assets is what makes daily work possible, so physically splitting them apart was never a realistic option.
Which means the files I want an agent to read and the files it must never touch are mixed together a few levels down from the same root. Once you add that root to permissions.allow, the precision of your deny rules becomes your actual security posture.
That was enough on the day I wrote it. It stopped being enough because the tree kept growing.
✦
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 will be able to list exactly which files your deny rules reach today, instead of assuming the rules you wrote are doing their job
✦You will be able to close the gap where a backup extension or a rename quietly pushes a secret outside your patterns, before it ever costs you
✦You will be able to choose a two-layer defence that combines deny rules with how you lay out the tree itself
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.
The problem with reading a config file is that it tells you nothing about how many files it currently covers. .env is listed, so .env is safe. But what about .env.production? What about apps/ios/config/AuthKey_ABC123.p8?
Recalling them one at a time was hopeless, so I wrote a small script that pulls the deny Read rules out of the settings file and applies them to the real tree.
#!/usr/bin/env python3"""List which files in the working tree are actually reached by theRead rules in permissions.deny, and report the secret-looking filesthat no rule covers."""import json, re, sysfrom pathlib import Path# What "looks like a secret" here. Extend this for your own stack.SECRET_HINTS = [ "**/.env*", "**/*.pem", "**/*.p8", "**/*.key", "**/*keystore*", "**/*credential*", "**/*token*", "**/*secret*", "**/.dev.vars", "**/google-services.json", "**/Keys.plist",]def read_deny_patterns(settings_path: Path): """Extract only the Read(...) rules from permissions.deny""" if not settings_path.exists(): return [] data = json.loads(settings_path.read_text()) rules = data.get("permissions", {}).get("deny", []) out = [] for r in rules: m = re.fullmatch(r"Read\((.+)\)", r.strip()) if m: out.append(m.group(1).strip()) return outdef expand(root: Path, pattern: str): """Drop a leading ./ and glob. If a pattern hits a directory, expand its contents recursively as well.""" pat = pattern[2:] if pattern.startswith("./") else pattern hits = set() for p in root.glob(pat): if p.is_dir(): hits.update(q for q in p.rglob("*") if q.is_file()) elif p.is_file(): hits.add(p) return hitsdef main(root_str=".", settings_str=".claude/settings.json"): root = Path(root_str).resolve() patterns = read_deny_patterns(root / settings_str) if not patterns: print(f"No deny Read rules found in {settings_str}") return 1 covered = set() print("== Files actually reached by deny rules ==") for pat in patterns: hits = expand(root, pat) covered |= hits print(f" {pat} -> {len(hits)} file(s)") for h in sorted(hits)[:3]: print(f" {h.relative_to(root)}") suspects = set() for hint in SECRET_HINTS: suspects |= expand(root, hint) gap = sorted(suspects - covered) print("\n== Secret-looking files no deny rule covers ==") if not gap: print(" none") for g in gap: print(f" !! {g.relative_to(root)}") # Non-zero exit when anything is uncovered, so CI and hooks can use it return 1 if gap else 0if __name__ == "__main__": sys.exit(main(*sys.argv[1:]))
Three things matter here.
First, expand() recurses into directories with rglob. A rule like Read(./secrets/**) means both the directory and everything under it. Skip that expansion and your counts come out smaller than reality.
Second, SECRET_HINTS is maintained separately from the deny rules. If you only inspect the rules, all you learn is that the things you wrote down are covered. Defining what you want protected independently, then taking the set difference, is what makes the gap visible.
Third, it exits non-zero when anything is uncovered, so you can call it from a pre-commit hook or a scheduled job.
Two lines of config, eight files walking straight past
Two rules, two files covered. Ten files I wanted covered.
Written out like that it looks obvious, but while staring at the config file all I had was the feeling that .env was handled. The fact that .env.local is a different filename simply did not register. The further apart the day you wrote the rule and the day the file appeared, the wider that gap between feeling and reality gets.
One rename and the pattern stops matching
Before rewriting anything, I noticed something worse.
The pattern **/.env* catches names that begin with .env. It does not catch env.old, which was sitting in the same folder. Checked directly:
Removing a single leading dot moves the file into a different pattern universe.
What made this genuinely uncomfortable is that it was not only a deny-rule problem. My own SECRET_HINTS list missed env.old too. So that "eight files" figure from the first scan may well have been nine. The detection side of the script deserved no more trust than the rules it was auditing, and it took my own tooling to teach me that.
Adding an extension, dropping the leading dot, appending a date — the things people do without thinking when taking a backup are exactly the things that fall outside pattern matching. The awkward part is that you are most likely to trip over this while doing something sensible: taking a copy before swapping configuration is supposed to be good practice.
Pattern
What it catches
What slips past
Read(./.env)
Only .env at the root
Every same-named file in subdirectories
Read(./**/.env)
.env at any depth
Variants like .env.local
Read(./**/.env.*)
.env.local / .env.bak
env.old (no leading dot)
Read(./**/*env*)
Anything with env in the name
Secrets you cannot identify by filename
Read(./secrets/**)
Everything under that folder
The same kind of file stored elsewhere
That last row is the real point. Protecting things by name carries a built-in ceiling: it cannot protect what the name does not reveal.
The rewritten deny list
What I ended up with, after adding rules until the gap hit zero:
Notice that ./**/*.pem and ./**/*.key sit there matching zero files, and I kept them anyway.
There are no such files right now. The value of a rule is not the count it matches today; it is that it will fire on its own the day such a file appears. A zero-match rule is not dead weight, it is a reservation.
The corollary matters more: when this script shows you a rule matching nothing, do not read that as permission to delete it. I nearly did. The only time pruning is the right call is when the ruleset has grown large enough that per-turn matching cost becomes the problem — a separate concern I covered in why sessions with stacked permission rules get heavier every turn.
August 2026 changed one of my assumptions
There was an unspoken assumption underneath all of this: that deny rules exist to guard the territory outside what you allowed.
In Claude Code v2.1.236, the handling of wildcard read-deny rules on macOS changed. A rule like **/.env now takes precedence even inside a directory you granted read access to. Matched directories have their contents covered too, and renaming a file to slip around a rule got considerably harder.
Which tells you something about how it worked before: the inside of an allowed directory was the weak spot. That is exactly the territory tools/backup/.env.bak was sitting in when I found it.
Looking at my configuration again after the change, it reads differently. Deny rules are not an exceptional door you close off; they are a locked drawer you keep inside a room you have opened. Given that framing, erring on the side of writing them too finely feels like the right trade to me.
Matching on names alone will never carry the whole load. After the audit, the first changes I made were to the tree, not to the config.
Move backups out of the working tree. I dropped the habit of parking old configuration in places like tools/backup/ and now pull values from storage when I need them. This was the single most effective change: no rule required, because the target is simply gone.
Pass values through the process, not through files. Build scripts no longer read those files directly; the values arrive as environment variables. If no read happens, no denial is needed.
Write rules for whatever is left. Once only the files that genuinely must live in the tree remain, the ruleset shrinks and each line has an obvious reason to exist.
The order is what matters. Thicken the deny list first and you lose all motivation to tidy the tree. Tidy first and the necessary rules narrow down on their own. I had that order backwards for half a year, and the backup file at the top of this article is what it cost me.
Verifying it under unattended runs
For scheduled or background sessions, you need a way to confirm the configuration was loaded at all.
An interactive session can open /permissions and look. An unattended one has nobody watching. I put the audit script at the front of the job and let a non-zero exit stop the work before it starts.
# Audit first; refuse to start the job if anything is uncoveredpython3 tools/scan_deny.py . .claude/settings.json || { echo "Gaps found in deny rules. Aborting." exit 1}
There is also the case where the settings file itself is malformed and never loads. The script returns 1 when it finds zero rules, so that path stops here too. What Claude Code does when its configuration is broken is covered in the safe-mode behaviour of a broken settings.json.
What to do next
Run the script at the top of whatever tree you are working in right now. It takes a few seconds.
If it reports zero gaps, that is zero gaps as of today. Run it again in three months and something will almost certainly have appeared. When it does, my suggestion is to resist adding a rule first and ask instead why that file is sitting there at all.
Hardening the defence feels productive. Reducing what needs defending pays better over time.
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.