A deny rule stops Claude from opening .env. It doesn’t stop the 176 packages Express’s tests can load. One stdlib script counts yours.
A Claude Code deny rule keeps Claude from reading your .env. It does not stop the 176 packages that npm test can load in Express.
The gap comes from Lydia Hallie’s talk “Rethinking Claude Code Best Practices”, posted to the AI Engineer channel on November 12, 2026. Lydia is on Anthropic’s Claude Code team.
After reading this you can count the packages that run when your agent types npm test, and turn on the setting that makes your .env block hold for all of them.
The talk updates Claude Code habits for auto mode and dozens of parallel sessions. On safety, she separates three layers:
Her example: you add a deny rule so Claude never reads .env. Then Claude runs npm test, which auto mode allows. The tests execute code you didn't write. If one dependency is compromised, it can open .env or your SSH keys and send them away. Permissions decide whether a tool call runs, not what it can reach once running.
The Claude Code permissions page draws the same line. Read and Edit deny rules cover Claude’s built-in file tools and the file commands Claude Code recognizes in Bash, such as cat, head, tail and sed. They do not cover "arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself."
A test runner is exactly that: every file Mocha, Jest or Vitest loads can call fs.readFileSync('.env').
The page’s own advice: for OS-level enforcement, enable the sandbox. The sandboxing page adds that it is off by default, uses Seatbelt on macOS and bubblewrap on Linux and WSL2, and covers commands “and their child processes”.
The deny rule checks the command Claude types. The sandbox walls in everything it starts.
I counted “code you didn’t write” for one well-known project. I cloned Express (5.3.0, commit 43fca52) and resolved its dependency tree with npm install --package-lock-only --ignore-scripts. That writes only package-lock.json: no tarballs, no install scripts. Express commits no lockfile, so counts drift as versions change.
Then a script reads the test command from package.json, maps mocha to its package, scans 124 test, example and library files for the packages they require, and walks the lockfile graph from there.
Result: 176 distinct packages (208 installed copies) can run when Claude types npm test in Express. The lockfile holds 403; the rest are linters, coverage tools and example-only dependencies. All 176 can run on every test, with your shell's access.
It is a lockfile estimate, not a trace. A package in the graph may never load, which inflates it; a computed require(name), a .mocharc file or a pretest script are invisible, which deflates it. I did not run the suite; needing no execution means you can point it at a repo you don't trust yet.
npmreach.py is stdlib-only. It reads JSON and source files and runs nothing.
#!/usr/bin/env python3"""npmreach.py - which npm packages run when your agent types `npm test`.Reads JSON and source files only. Executes nothing.usage: python3 npmreach.py <project-dir> [script-name]"""import json, os, re, sys, shlex, textwrapREQ = re.compile(r"""(?:require\(|import\s*\(|from\s+|import\s+)['"]([^'"]+)['"]""")BUILTIN = set("""assert async_hooks buffer child_process cluster consoleconstants crypto dgram dns domain events fs http http2 https inspector modulenet os path perf_hooks process punycode querystring readline repl streamstring_decoder timers tls tty url util v8 vm worker_threads zlib""".split())def wrap(s, w=78): return "\n".join(textwrap.wrap(s, w, subsequent_indent=" ", break_on_hyphens=False))def pkg_name(spec): parts = spec.split("/") return "/".join(parts[:2]) if spec.startswith("@") else parts[0]def resolve_file(base): for c in (base, base + ".js", base + ".mjs", base + ".cjs", os.path.join(base, "index.js")): if os.path.isfile(c): return c return Nonedef scan_sources(root, starts): """Follow relative requires from the test entry points; return the set of bare package names the test code itself loads.""" todo, seen, bare = [], set(), set() for s in starts: p = os.path.join(root, s) if os.path.isdir(p): for d, _, fs in os.walk(p): if "node_modules" in d: continue todo += [os.path.join(d, f) for f in fs if f.endswith((".js", ".mjs", ".cjs", ".ts"))] elif resolve_file(p): todo.append(resolve_file(p)) while todo: f = os.path.realpath(todo.pop()) if f in seen or "node_modules" in f: continue seen.add(f) src = open(f, encoding="utf-8", errors="replace").read() for spec in REQ.findall(src): if spec.startswith("."): r = resolve_file(os.path.join(os.path.dirname(f), spec)) if r: todo.append(r) elif re.search(r"\s", spec): continue elif not spec.startswith("node:") and pkg_name(spec) not in BUILTIN: bare.add(pkg_name(spec)) return bare, len(seen)
It follows ./ imports and keeps non-built-in package names.
def main(): if len(sys.argv) < 2 or not os.path.isfile( os.path.join(sys.argv[1], "package-lock.json")): sys.exit("usage: npmreach.py <dir with package-lock.json> [script]") root = sys.argv[1] script = sys.argv[2] if len(sys.argv) > 2 else "test" pj = json.load(open(os.path.join(root, "package.json"))) lock = json.load(open(os.path.join(root, "package-lock.json")))["packages"] cmd = pj.get("scripts", {}).get(script) if not cmd: sys.exit(f"no '{script}' script in package.json") bins = {} for path, e in lock.items(): b = e.get("bin") or {} for name in (b if isinstance(b, dict) else {}): if path.count("node_modules/") == 1: bins[name] = path words = shlex.split(cmd.replace("&&", " ").replace("||", " ")) runners = [bins[w] for w in words if w in bins] files = [w for w in words if not w.startswith("-") and w not in bins] bare, nfiles = scan_sources(root, files) if pj.get("name") in bare: # the test suite requires the package itself bare.discard(pj["name"]) bare.add(".") # code under test: its runtime deps load too (assumption) def find(from_path, name): p = from_path while True: c = (p + "/" if p else "") + "node_modules/" + name if c in lock: return c if not p: return None i = p.rfind("/node_modules/") p = p[:i] if i >= 0 else "" reached, todo = set(), list(runners) for n in bare: hit = "" if n == "." else find("", n) if hit is not None: todo.append(hit) while todo: p = todo.pop() if p in reached: continue reached.add(p) e = lock[p] deps = {**e.get("dependencies", {}), **e.get("optionalDependencies", {})} for n in deps: hit = find(p, n) if hit: todo.append(hit) reached.discard("") total = len([k for k in lock if k]) names = sorted({k.rsplit("node_modules/", 1)[1] for k in reached}) print(f"project : {pj.get('name')} {pj.get('version')}") print(wrap(f"script : npm run {script} -> {cmd}")) print(f"runner bins : {', '.join(r.split('/')[-1] for r in runners) or '-'}") print(f"test sources : {nfiles} files scanned (relative requires followed)") print(f"test requires: {len(bare) - 1} packages by name") print(f"in lockfile : {total} packages") print(f"can run : {len(names)} packages ({len(reached)} installed copies)") print(" basis : lockfile graph from runner + requires, not a trace") check_settings(root)
find copies npm's lookup: beside the package, then one level up at a time.
PROJECT = {".env", "./.env", "/.env", "**/.env", "**", "./**", "/**"}def covers(path, user=False): """Does a path name this project's .env? (user sandbox '.' = ~/.claude)""" p = path.removeprefix("Read(").removesuffix(")") p = p[:-1] if p.endswith(".env*") else p return p in ({"**/.env", "**"} if user else PROJECT)def check_settings(root): """Is .env blocked only for Claude's own tools, or for every process?""" files = [os.path.expanduser("~/.claude/settings.json"), os.path.join(root, ".claude/settings.json"), os.path.join(root, ".claude/settings.local.json")] rules, sandbox_on, deny_read, excluded = [], False, [], [] for n, f in enumerate(files): if not os.path.isfile(f): continue cfg = json.load(open(f)) rules += [r for r in cfg.get("permissions", {}).get("deny", []) if r.startswith("Read(") and covers(r)] sb = cfg.get("sandbox", {}) sandbox_on = sb.get("enabled", sandbox_on) deny_read += [p for p in sb.get("filesystem", {}).get("denyRead", []) if covers(p, n == 0)] excluded += sb.get("excludedCommands", []) print(f"Read deny : {rules or 'none'}") print(f"sandbox : enabled={sandbox_on} " f".env denyRead={deny_read or 'none'}") npm_out = [c for c in excluded if c.split()[0] in ("npm", "npx", "node")] if sandbox_on and npm_out: v = f"sandbox on, but {npm_out[0]} runs unsandboxed" elif sandbox_on and deny_read: v = "OS blocks .env for sandboxed commands and their children" elif sandbox_on and rules: v = "OS blocks .env via the merged Read rule; add denyRead too" elif rules: v = "blocks Claude's Read tool and cat; NOT a script npm test runs" else: v = "nothing found that blocks .env" print(wrap(f"verdict : {v}"))if __name__ == "__main__": main()
It reads user and project settings, accepts only rules that name this project’s .env, and checks excludedCommands.
git clone --depth 1 https://github.com/expressjs/express.gitcd express && npm install --package-lock-only --ignore-scripts && cd ..python3 npmreach.py express
Real stdout:
project : express 5.3.0script : npm run test -> mocha --require test/support/env --reporter spec --check-leaks test/ test/acceptance/runner bins : mochatest sources : 124 files scanned (relative requires followed)test requires: 39 packages by namein lockfile : 403 packagescan run : 176 packages (208 installed copies) basis : lockfile graph from runner + requires, not a traceRead deny : nonesandbox : enabled=False .env denyRead=noneverdict : nothing found that blocks .env
Mutation check: replacing every require('supertest') in a copy of the tests with a local file drops the count to 158. A bad path exits 1 with a usage line.
Add a Read deny rule and an explicit sandbox read block to .claude/settings.json in the project:
{ "permissions": { "deny": ["Read(./.env)"] }, "sandbox": { "enabled": true, "filesystem": { "denyRead": ["./.env", "~/.ssh"] } }}
The deny rule covers Claude’s Read tool and cat .env. The sandbox covers npm test and everything it starts; the docs say permission rules are merged into its config, and denyRead makes it explicit. ~/.ssh is there because, as Lydia notes, the sandbox can read most of your machine by default. Keep this in the project file: in user settings, the sandbox's . means ~/.claude.
The deny rule alone, then the full block:
Read deny : ['Read(./.env)']sandbox : enabled=False .env denyRead=noneverdict : blocks Claude's Read tool and cat; NOT a script npm test runs
Read deny : ['Read(./.env)']sandbox : enabled=True .env denyRead=['./.env']verdict : OS blocks .env for sandboxed commands and their children
Three cautions. On Linux, per the sandbox-runtime README (0.0.83), denyRead resolves when each command starts, so a path that doesn't exist yet hides nothing. Claude's file tools, command hooks and local MCP servers run outside the sandbox, so keep the deny rule. Where the sandbox can't run, such as native Windows, commands run unsandboxed unless sandbox.failIfUnavailable is true, which makes Claude Code exit at startup instead.
The talk’s stronger answer is a cloud session: its own machine, a fresh clone, no .env (not in git), no SSH keys. API keys go in as network secrets the model never sees, and network access starts limited to things like package registries and GitHub. The machine is disposable, but what leaves it isn't, so Lydia commits her deny rules to the repo.
A Claude Code deny rule is a check on the command Claude types, not on what that command runs. In Express, npm test can reach 176 packages, and none of them is covered by Read(./.env). The sandbox with an explicit denyRead, or a cloud session, is what keeps .env out of reach.
How many packages does your npm test reach? Post the number in the comments.
To reproduce: npmreach.py produces every count and verdict here; the 158 needs the hand edit described in section 08. Talk claims come from its transcript.
If this helped, a clap helps others find it.
Follow me for daily LLM deep-dives.
Claude Code Can’t Read Your .env. Your npm test Still Can was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.