Bash tool sets redacted/direnv-managed env vars to the literal string `"null"` instead of unsetting them
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.
direnvactive in the project, injecting ~30 project env vars (viadotenvx).
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
claudeprocess's environment (ps eww) holds the real values. - Only the Bash-tool subshell sees
"null". dangerouslyDisableSandbox: truemakes 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.