Shell snapshot overrides `.zshenv`, breaking cwd-aware shell setup
Shell snapshot overrides .zshenv, breaking cwd-aware shell setup
Summary
Claude Code's per-session shell snapshot (~/.claude/shell-snapshots/snapshot-zsh-*.sh) captures export PATH=... from the parent shell at launch time, then sources that snapshot in every Bash tool invocation. This silently overrides any PATH adjustment made by ~/.zshenv, breaking cwd-aware setup like nvm auto-switching for non-interactive subshells.
The result: shell-level configuration cannot reliably control the environment of Bash tool calls. Users who want per-project Node/Python/etc. versions in Bash tool subshells are forced into per-command PreToolUse hooks that prepend setup to every command, which in turn pollutes the command string seen by the permissions allowlist.
Environment
- macOS 25.3.0 (Darwin)
- Claude Code 2.1.132 (VS Code extension)
$SHELL=/bin/zsh, zsh 5.9- nvm with multiple Node versions installed; per-project
.nvmrcpinning
Observed behavior
The Bash tool spawns:
/bin/zsh -c "source <snapshot.sh> 2>/dev/null || true && <env exports> && setopt NO_EXTENDED_GLOB 2>/dev/null || true && eval '<user command>' < /dev/null && pwd -P >| /tmp/claude-ccfa-cwd"
Order of events inside that single invocation:
- zsh starts, sources
~/.zshenv(e.g., loads nvm, runsnvm useagainst the current.nvmrc, prepends the right Node bin toPATH). - The
-cbody runs andsources the snapshot, which contains a verbatimexport PATH=...v22.11.0...captured at launch. - The snapshot's
PATHoverwrites whatever.zshenvset. eval '<user command>'runs with the launch-timePATH, not the cwd-aware one.
Confirmed by tracing: a sentinel echo ... >> /tmp/zshenv-trace in .zshenv does fire, then which node after the eval still resolves to the snapshot-captured version, not the .nvmrc version.
Why it matters
Common workaround is a PreToolUse Bash hook that prepends nvm sourcing to the command:
{
"type": "command",
"command": "jq -c '.tool_input.command = (\"export NVM_DIR=...; nvm use --silent...; \" + (.tool_input.command // \"\")) | ...'"
}
This works but has a serious side effect: the rewritten command is what the permissions matcher sees. Allowlist rules like Bash(npm run lint) no longer match because the actual command is now export NVM_DIR=...; nvm use --silent...; npm run lint. Users either lose auto-permissions entirely or must contort their allowlist with prefix patterns that will rot.
Neither workaround is correct:
- Pre-command hook → pollutes permission matching.
~/.zshenv→ loses to the snapshot..zshrc→ only sourced for interactive shells, never forzsh -c.chpwdhook → only fires oncd, not at shell startup, and is in.zshrcanyway.precmd/preexec→ don't fire inzsh -cnon-interactive mode.
There is currently no first-class way to have cwd-dependent environment in Bash tool subshells.
Suggested fixes (any one would help)
- Source
.zshenvafter the snapshot, or run a user-controlled rcfile after the snapshot. A small "post-snapshot user hook" file (e.g.,~/.claude/shell-postinit.sh) that gets sourced at the end of the wrapper, after the snapshot but beforeeval, would let users donvm use(or equivalent) per invocation without rewriting commands. - Don't bake
PATHinto the snapshot at launch time. Capture functions, aliases, options, and exported scalars, but let the live shell's startup files determinePATH. This is the more invasive fix but the most correct: snapshots should restore the user's configuration, not freeze a stale environment that supersedes that configuration. - Have
PreToolUsehooks run AFTER the permission check, not before. Then the existing per-command nvm prefix hook would be invisible to the matcher and the workaround would be acceptable. Schema already hasif: "Bash(...)"filtering, but ordering is the real issue. - Document the snapshot mechanism and its precedence. Right now there's no indication in user-facing docs that
.zshenvwill lose to a capturedPATH. At minimum, this should be called out so people don't waste time chasing it.
Repro
# 1. Have nvm + .zshrc with the standard auto-switch hook on chpwd.
# 2. Have a project with .nvmrc pinning a non-default Node version.
# 3. Create ~/.zshenv:
cat > ~/.zshenv <<'EOF'
if [ -n "$CLAUDECODE" ]; then
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use >/dev/null 2>&1
[ -f .nvmrc ] && nvm use --silent >/dev/null 2>&1
fi
EOF
# 4. Open Claude Code in the project from a shell whose default Node differs
# from .nvmrc.
# 5. Ask Claude to run `node --version`.
# Expected: the .nvmrc version.
# Actual: the launch-time default.
Happy to provide trace output or test alternate fixes if useful.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗