CCLearn

Secrets, .env, and what leaves your machine

verified against Claude Code 2.1.261 · 2026-09-0912 min

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:

json
{
  "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

SurfaceCovered by a Read deny rule?
The Read toolYes
Grep, Glob, @file mentions, IDE selection contextYes, documented as best-effort
File discovery and search resultsExcluded from them
Bash file commands Claude Code recognizes: cat, head, tail, sedYes
Bash output redirect targets, > fileYes
Bash input redirect targets, < fileYes — v2.1.257
Edit and Write on the same path, new files includedYes — v2.1.208 on edits, v2.1.228 on writes
NotebookEditNo; add a separate Edit deny rule
A subprocess that opens the file itselfNo

"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.

json
{
  "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, or npm, not only to Claude's file tools. Claude Code adds your permission rules to the same lists: Edit allow and deny rules to allowWrite and denyWrite, Read deny rules to denyRead, and WebFetch(domain:...) allow and deny rules to the network domain 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

LayerStopsDoes not stop
permissions.deny Read rulesTool reads, recognized Bash file commands, discovery and searchA script that opens the file itself
The sandbox, with those rules merged into denyReadEvery subprocess of a sandboxed commandBuilt-in file tools, hooks and MCP servers, which run on the host
CLAUDE_CODE_SUBPROCESS_ENV_SCRUBCredentials reaching the Bash tool, hooks, MCP stdio serversA secret already sitting in a file
cleanupPeriodDays, CLAUDE_CODE_SKIP_PROMPT_HISTORYA read that happened persisting on diskThe 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

  1. You add Read(./.env) to permissions.deny, and Claude duly refuses to cat it — then runs npm run seed, which loads .env itself and logs the connection string. Which layer did you skip, and what does adding it change about that same rule?

    Show answer

    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 cat because cat is one of the Bash file commands Claude Code recognizes, and it never saw the open that happened inside the process npm run seed launched. 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 — Read deny rules to denyRead, Edit allow and deny to allowWrite and denyWrite, WebFetch(domain:...) to the network lists — and enforces them at the OS boundary, so the same Read(./.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, and denyRead is not enforced with the filesystem layer disabled.

  2. A colleague says turning the sandbox on protects ~/.ssh with no further configuration. Which documented default contradicts that, and which two settings would actually restrict it?

    Show answer

    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/credentials and ~/.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.credentials with a files entry — { "path": "~/.ssh", "mode": "deny" }, available from v2.1.187 — makes the path unreadable inside the sandbox; and a Read rule in permissions.deny, which Claude Code folds into the same denyRead list 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.

  3. sandbox.credentials and CLAUDE_CODE_SUBPROCESS_ENV_SCRUB both remove credentials from an environment. Which processes does each cover, and which still works with the sandbox off?

    Show answer

    sandbox.credentials "affects sandboxed Bash commands only": a deny environment entry is unset before each sandboxed command, and a deny file entry becomes unreadable inside the sandbox. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB set to 1 is 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.credentials needs the sandbox, and its file half additionally needs the filesystem isolation layer; only its environment half survives that being turned off.

  4. A teammate ran /bug after 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 answer

    A copy of the conversation history including code went to Anthropic — /bug, /feedback and /share all 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=1 opts 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, the cleanupPeriodDays value. Lowering that only shortens how long the copy survives, and CLAUDE_CODE_SKIP_PROMPT_HISTORY would have kept it off disk entirely; neither changes what the model already saw. Only denying the read does that.

Sources