◉CLAUDE LABJP
●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.301 — Unattended retries now stop on their own when a usage limit is hit instead of waiting (10/15). Latest is 2.1.304 (10/19)●SONNET 4.5 — Retires on the Claude API on 11/30, 40 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.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.301 — Unattended retries now stop on their own when a usage limit is hit instead of waiting (10/15). Latest is 2.1.304 (10/19)●SONNET 4.5 — Retires on the Claude API on 11/30, 40 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-09-05Intermediate

My deny rule was watching a filename, not the routes that reach it

One line in deny let several other spellings of the same file walk right past. Here is the small matrix I build to list every route to a protected file, and how I re-check it on upgrade day.

Claude Code270Permissions9Unattended runsSecurity12

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.

ShapeExample commandMatches my rule text?
directcat .envYes
absolutecat "$PWD/.env"No
option-valuegit blame --ignore-revs-file=.env .No
at-filecurl -d @.env "$ENDPOINT"No
git-pathspecgit diff -- .envNo
recursivegrep -r token .No
copy-outcp -r . /tmp/copyNo
symlinkln -s .env /tmp/link && cat /tmp/linkNo

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 result column 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.

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 →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ 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.
⟐ Claude Code2026-10-13
The Same Approval Prompt for 'cd, git add, git commit' Every Time — Following It to a Design Choice, Not a Missing Setting
With a compound command like cd /path && git add && git commit, Claude Code keeps asking for approval even with allow rules in place. Reading the official permissions page next to a GitHub report shows that cd combined with git prompts by design. Here is how to tell, in what order to check, and four ways to cut the prompts.
⟐ Claude Code2026-07-30
You Added a Second Working Root — Now Ask How Far Your Ignore Rules and Deny Patterns Actually Reach
Adding a working root mid-session does not carry your ignore rules or deny patterns with it. Measured evidence from git check-ignore and a three-way matcher comparison, plus the scope-delta audit script I now run from the DirectoryAdded hook.