Bash tool sets redacted/direnv-managed env vars to the literal string `"null"` instead of unsetting them

Open 💬 0 comments Opened Jun 25, 2026 by disi-omni

Summary

When the Bash tool spawns a subprocess, a large set of environment variables that exist
with real values in the parent claude process arrive in the subprocess as the literal
string "null"
rather than being unset. This silently breaks any subprocess that reads
those variables — a "null" value passes presence checks (os.environ.get(X) is truthy)
and then fails downstream parsing/validation, where an unset var would have correctly
fallen back to a default.

Environment

  • Claude Code (CLI), macOS (darwin), zsh.
  • direnv active in the project, injecting ~30 project env vars (via dotenvx).

What I observed

In a Bash tool subprocess, 32 env vars had the exact value "null". They are
precisely the direnv-managed + secret-bearing set — e.g. database credentials, signing
secrets, API keys, VIRTUAL_ENV, PYTHONDONTWRITEBYTECODE (a plain export FOO=1 in
.envrc), and cloud-provider profile/region vars.

Evidence that the real values exist and only the Bash subprocess is affected:

  • direnv's own diff (DIRENV_DIFF, the authoritative "new env" map) holds the real

values (e.g. AWS_PROFILE=dev, VIRTUAL_ENV=/…/.venv, a real API key).

  • The parent claude process's environment (ps eww) holds the real values.
  • Only the Bash-tool subshell sees "null".
  • dangerouslyDisableSandbox: true makes no difference — identical "null" values,

so it is not the sandbox.

  • No shell snapshot under ~/.claude/shell-snapshots/ contains these vars, and a clean

login shell resolves the real values. So it is not the user's dotfiles, launchd, or the
project config.

Why it's harmful (not just cosmetic)

"null" is a present, truthy, non-empty string, so it defeats the unset-fallback path:

  • A cloud SDK reads the profile var, treats "null" as a real profile name, and raises

ProfileNotFound at client construction — before any test mock can intercept. (This is
what first surfaced it: a whole moto-backed test suite errored under the agent that is
green in CI.)

  • A typed settings layer that parses booleans/ints raises a hard validation error:

BOOL_FLAG: must be one of true/1/yes or false/0/no for BOOL_FLAG="null", and an int
parse error for PORT="null". An unset var would have used the declared default; the
string "null" turns "use default" into "crash."

  • VIRTUAL_ENV="null" and similar can confuse tooling that trusts the variable's

presence.

Expected vs actual

  • Expected: a variable with no value is unset (absent) in the subprocess env, so

consumers take their unset/default path. (Redacting secrets from the agent's shell is
reasonable and arguably desirable — the defect is purely the representation.)

  • Actual: the variable is present with value "null".

Suggested fix

When redacting/omitting an env var for a spawned subprocess, delete the key rather
than serialize a null/None to the string "null". (Looks like a JSON.stringify(null) /
String(null) slipping into an env map that requires string values.)

Workaround (for affected projects)

Make tests/entrypoints hermetic to the injected env — e.g. an autouse fixture that clears
the affected vars (or pins them to the CI-equivalent values) before clients/settings are
constructed. This is a downstream band-aid; the clean fix is unset-don't-stringify.

View original on GitHub ↗