Secrets, .env, and what leaves your machine
By the end you can
- Write deny rules that keep secret files out of reads, edits and search results
- State the reach and the limits of a Read deny rule
- Say what blockReadsOutsideWorkingDirectories fences, and from which version
- Restrict credential files and environment variables at the sandbox boundary
- Account for what leaves the machine, from transcripts to feedback uploads
Somewhere in your repo is a .env with a live database URL in it. Nothing so far
has stopped Claude reading it — and in auto mode it does not even ask, because
reading .env and sending credentials to their matching API sits on the
allow-by-default list you met in Lesson 10.3. That read is not one event: the
value enters the conversation, the conversation goes to the model, and the
transcript lands on your disk in plaintext. This lesson closes all three, and
names the popular fix that only closes the first.
The deny block that belongs in every repo
permissions.deny is the documented home for it, and it does more than refuse to
open the file:
Use it for files that hold API keys, secrets, or environment values: Claude Code excludes matching files from file discovery and search results, denies reads of them, and blocks the Edit and Write tools on the matching paths. — Settings reference
The docs' own paste-ready example:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(./config/credentials.json)",
"Bash(curl *)"
]
}
}Three mechanics decide whether that block covers what you meant. path and
./path are the same anchor, the current directory, and Read and Edit rules use
gitignore pattern syntax — where a relative single-segment directory pattern
matches at a depth that depends on the rule type. As a deny or ask rule,
Read(secrets/**) matches a secrets directory at any depth under the
current directory, so it catches a nested copy under vendor/; as an allow rule
the identical pattern matches <cwd>/secrets only. Bare filenames match at any
depth too, so Read(.env) and Read(**/.env) are documented as equivalent, and
neither reaches a .env in a parent directory or another project. And write file
rules for Read or Edit only, the point Lesson 10.2 made: a path rule for
Write, Glob, NotebookEdit or MultiEdit is accepted and never consulted —
except a Glob rule passed in --allowedTools. The startup warning that catches
that mistake arrived in v2.1.210; the rule is inert either way, and an older
build simply never tells you.
How far a Read deny rule reaches
| Surface | Covered by a Read deny rule? |
|---|---|
The Read tool | Yes |
Grep, Glob, @file mentions, IDE selection context | Yes, documented as best-effort |
| File discovery and search results | Excluded from them |
Bash file commands Claude Code recognizes: cat, head, tail, sed | Yes |
Bash output redirect targets, > file | Yes |
Bash input redirect targets, < file | Yes — v2.1.257 |
Edit and Write on the same path, new files included | Yes — v2.1.208 on edits, v2.1.228 on writes |
NotebookEdit | No; add a separate Edit deny rule |
| A subprocess that opens the file itself | No |
"Best-effort" is the documentation's own word for the Grep, Glob and @file
row, a weaker claim than the Read tool row above it. The Edit/Write row
arrived in two steps — v2.1.208 for edits, v2.1.228 for writes, new files at the
path included — so on a build below those lines a Read deny rule refuses to
open the file and lets a write to it through. Check claude --version before you
count that half.
The two redirect rows did not arrive together either, and the second one is the
half people assume. The docs say a < file target "covers your Read allow and
deny rules and the working directories", then date it: "Claude Code checks input
targets in v2.1.257 and later"
(Configure permissions). Below that
line an output redirect into a denied path is caught and an input redirect out of
one is not, so a Read(./.env) rule refuses cat .env — a recognized file
command — and lets base64 < .env through with the file's contents arriving on
stdin. That is the shape every workaround takes: not an attack on the rule, just a
spelling the rule was not reading. The row closed in v2.1.257; the general lesson
did not close with it. A deny rule covers the spellings Claude Code recognizes,
and the layer that does not care how a read is spelled is the sandbox below it.
Where it stops
Here is the trap. A deny rule is a check Claude Code runs before a tool call, and a subprocess is not a tool call.
The permissions page, on the reach of Read and Edit deny rules:
They don't apply to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself. For OS-level enforcement that blocks all processes from accessing a path, enable the sandbox. — Configure permissions
Fencing reads outside the working directories
A deny rule enumerates what you thought of;
permissions.blockReadsOutsideWorkingDirectories inverts that. It stops the
Read, Grep, Glob and LSP tools reading paths outside the session's working
directories "in every permission mode including bypassPermissions", and makes
a recognized Bash file command such as cat prompt you even in auto mode and
bypassPermissions mode. The files Claude Code itself needs — your skills,
plugins, rules, agents, commands and the CLAUDE.md under ~/.claude/ — stay
readable (Settings reference).
That page also dates it to v2.1.257, so it is a key a mixed-version team cannot
lean on until everyone is past that line. It ratchets one way: if any settings
source sets true the block applies, so a repository's checked-in file can turn
it on for a project but cannot lift a block you set.
Credentials and the sandbox
The sandbox is that OS-level layer, and turning it on is not by itself credential
protection: its default read behaviour is read access to the whole computer
except certain denied directories, and the docs add that this "still allows
reading credential files such as ~/.aws/credentials and ~/.ssh/". There is no
safety net underneath, because "there is no built-in credential deny list, so
only the files and variables you list are restricted"
(Sandboxing). sandbox.credentials
is where you list them, from v2.1.187 on — a deny file entry becomes unreadable
inside the sandbox, a deny environment entry is unset before each sandboxed
command. The file half belongs to the filesystem layer, so disabling filesystem
isolation drops it; the environment half survives.
{
"sandbox": {
"enabled": true,
"credentials": {
"files": [{ "path": "~/.ssh", "mode": "deny" }],
"envVars": [{ "name": "GITHUB_TOKEN", "mode": "deny" }]
}
}
}Deny entries only ever narrow access, so Claude Code merges them from every settings scope the session loads; no scope can remove one another scope added. And you do not restate your secret paths here, because the rules you already wrote are folded into the boundary:
Claude Code enforces these lists at the OS sandbox boundary, so they apply to every subprocess a sandboxed command starts, such as
kubectl,terraform, ornpm, not only to Claude's file tools. Claude Code adds your permission rules to the same lists:Editallow and deny rules toallowWriteanddenyWrite,Readdeny rules todenyRead, andWebFetch(domain:...)allow and deny rules to thenetworkdomain lists. — Settings reference
The same Read(./.env) rule that a Python script walked straight past becomes
a denyRead entry the operating system enforces on that script — once the
script runs as a sandboxed Bash command. Read the scope back: sandboxing
applies only to Bash commands and their child processes, and denyRead is not
enforced with the filesystem layer disabled. Inside that scope, one rule has two
very different strengths.
Scrubbing the subprocess environment
Secrets in files are half of it. Sandboxed Bash commands inherit the parent
process environment by default, credentials included — and sandbox.credentials
"affects sandboxed Bash commands only"
(Sandboxing). The documented tool
for everything else is CLAUDE_CODE_SUBPROCESS_ENV_SCRUB. Set to 1, it strips
credentials from subprocess environments — the Bash tool, hooks, and MCP stdio
servers — covering Anthropic and cloud provider credentials, anything else
Claude Code recognizes as a credential, and credentials embedded in package
registry URLs. The parent process keeps them so API calls still work; children
cannot read them. The documented motive is Lesson 10.6's threat model: reducing
exposure to prompt injection attacks that try to exfiltrate secrets via shell
expansion (Environment variables).
What is on disk after the session
Blocking the read matters partly because of where a successful read ends up.
Claude Code stores session transcripts locally in plaintext under
~/.claude/projects/ for 30 days by default, to enable session resumption
(Data usage). The .claude
directory reference is blunter: transcripts are not encrypted at rest, OS file
permissions are the only protection, and if a tool reads a .env file or a
command prints a credential, that value is written to
projects/<project>/<session>.jsonl. It lists four ways to reduce that
exposure; three matter here, and they are not interchangeable. Lowering
cleanupPeriodDays from its default of 30 only shortens how long the transcript
survives. CLAUDE_CODE_SKIP_PROMPT_HISTORY skips writing transcripts and prompt
history in any mode, so nothing lands on disk at all. Denying reads of credential
files is the one that keeps the value out of the conversation — and therefore the
only one of the three that changes what the model saw. Your own login is handled
better: on macOS it lives in the encrypted Keychain, falling back to ~/.claude/.credentials.json
at file mode 0600 when the Keychain rejects the write, such as in a locked SSH
session (The .claude directory,
Authentication).
What you send when you file feedback
Running /feedback sends a copy of your conversation history including code to
Anthropic, and /bug and /share submit through the same path — unless you are
on a third-party provider such as Bedrock or have no Anthropic credentials
configured, in which case it writes a redacted archive under
~/.claude/feedback-bundles/ and nothing leaves until you send that file. Those
transcripts are retained for five years, and the dialog lets you widen the scope
to other sessions from the same project over the last 24 hours or 7 days;
DISABLE_FEEDBACK_COMMAND=1 opts out. The separate "How is Claude doing this
session?" prompt records only your rating, and its follow-up uploads your
transcript only if you explicitly select Yes
(Data usage).
Two egress facts, both commonly assumed backwards. Blocking a domain at the tool
level is not blocking the network: using WebFetch alone "doesn't prevent network
access", and if Bash is allowed Claude can still use curl, wget or other
tools to reach any URL
(Permissions). And containment is
not confidentiality: isolation "does not change what is sent to the model" —
your prompts and the files Claude reads go to the Anthropic API or your provider
with or without a sandbox
(Sandbox environments).
Four layers, in the order to add them
| Layer | Stops | Does not stop |
|---|---|---|
permissions.deny Read rules | Tool reads, recognized Bash file commands, discovery and search | A script that opens the file itself |
The sandbox, with those rules merged into denyRead | Every subprocess of a sandboxed command | Built-in file tools, hooks and MCP servers, which run on the host |
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB | Credentials reaching the Bash tool, hooks, MCP stdio servers | A secret already sitting in a file |
cleanupPeriodDays, CLAUDE_CODE_SKIP_PROMPT_HISTORY | A read that happened persisting on disk | The read itself, or what was sent to the model |
Most people stop at row one. But nothing lower in the table changes what reaches the model — the sandbox contains a process, and the transcript settings act after the value has already been sent. Of the four, only the deny rule keeps a secret out of the conversation, which is why it is worth writing even though it is the weakest boundary listed.
Checkpoint
-
You add
Read(./.env)topermissions.deny, and Claude duly refuses tocatit — then runsnpm run seed, which loads.envitself and logs the connection string. Which layer did you skip, and what does adding it change about that same rule?Show answerAnswer
You skipped the sandbox, the OS-level layer under the rule. A deny rule is a check Claude Code runs before a tool call, and a subprocess is not a tool call: the rule caught
catbecausecatis one of the Bash file commands Claude Code recognizes, and it never saw the open that happened inside the processnpm run seedlaunched. The docs say it outright — these rules "don't apply to arbitrary subprocesses that read or write files indirectly", and for OS-level enforcement that blocks all processes from accessing a path, enable the sandbox.You do not restate the path to get that. Claude Code adds your permission rules to the sandbox's own lists —
Readdeny rules todenyRead,Editallow and deny toallowWriteanddenyWrite,WebFetch(domain:...)to the network lists — and enforces them at the OS boundary, so the sameRead(./.env)now applies to every subprocess a sandboxed command starts. Read the scope back before relaxing: sandboxing covers Bash commands and their child processes only, anddenyReadis not enforced with the filesystem layer disabled. -
A colleague says turning the sandbox on protects
~/.sshwith no further configuration. Which documented default contradicts that, and which two settings would actually restrict it?Show answerAnswer
The sandbox's default read behaviour contradicts it: read access to the whole computer except certain denied directories, which the docs say "still allows reading credential files such as
~/.aws/credentialsand~/.ssh/". There is no built-in credential deny list, so only the files and variables you list are restricted — nothing is protected by default.Two settings do the restricting.
sandbox.credentialswith a files entry —{ "path": "~/.ssh", "mode": "deny" }, available from v2.1.187 — makes the path unreadable inside the sandbox; and aReadrule inpermissions.deny, which Claude Code folds into the samedenyReadlist at the same boundary. Deny entries only ever narrow access, so they merge from every settings scope the session loads and no scope can remove one another added. Two caveats: the file half belongs to the filesystem layer and drops if you disable filesystem isolation, and if the worry is Claude's own tools rather than subprocesses,permissions.blockReadsOutsideWorkingDirectories(v2.1.257) stops Read, Grep, Glob and LSP outside the working directories in every mode. -
sandbox.credentialsandCLAUDE_CODE_SUBPROCESS_ENV_SCRUBboth remove credentials from an environment. Which processes does each cover, and which still works with the sandbox off?Show answerAnswer
sandbox.credentials"affects sandboxed Bash commands only": adenyenvironment entry is unset before each sandboxed command, and adenyfile entry becomes unreadable inside the sandbox.CLAUDE_CODE_SUBPROCESS_ENV_SCRUBset to1is the documented tool for everything else — it strips credentials from the environments of the Bash tool, hooks, and MCP stdio servers, covering Anthropic and cloud provider credentials, anything else Claude Code recognizes as a credential, and credentials embedded in package registry URLs. The parent process keeps them, so Claude Code's own API calls still work.The scrub is the one that still works with the sandbox off: it is an environment variable about subprocess environments, not a sandbox setting, and two of the three process kinds it names — hooks and MCP stdio servers — run on the host and were never inside the Bash sandbox to begin with.
sandbox.credentialsneeds the sandbox, and its file half additionally needs the filesystem isolation layer; only its environment half survives that being turned off. -
A teammate ran
/bugafter a session in which Claude read a staging.env. What left the machine, where is a copy still sitting locally, and for how long by default?Show answerAnswer
A copy of the conversation history including code went to Anthropic —
/bug,/feedbackand/shareall submit through the same path — and the credential was inside it, because reading the file put the value into the conversation. Those transcripts are retained for five years, and the dialog offers to widen the scope to other sessions from the same project over the last 24 hours or 7 days, so it may be more than the one session.DISABLE_FEEDBACK_COMMAND=1opts out. One exception: on a third-party provider such as Bedrock, or with no Anthropic credentials configured, it writes a redacted archive under~/.claude/feedback-bundles/and nothing leaves until that file is sent.Locally the transcript sits in plaintext at
~/.claude/projects/<project>/<session>.jsonl— not encrypted at rest, OS file permissions the only protection — for 30 days by default, thecleanupPeriodDaysvalue. Lowering that only shortens how long the copy survives, andCLAUDE_CODE_SKIP_PROMPT_HISTORYwould have kept it off disk entirely; neither changes what the model already saw. Only denying the read does that.
Sources
- code.claude.com/docs/en/settings-reference
- code.claude.com/docs/en/permissions
- code.claude.com/docs/en/permission-modes
- code.claude.com/docs/en/sandboxing
- code.claude.com/docs/en/sandbox-environments
- code.claude.com/docs/en/data-usage
- code.claude.com/docs/en/env-vars
- code.claude.com/docs/en/authentication
- code.claude.com/docs/en/claude-directory