Shell snapshot overrides `.zshenv`, breaking cwd-aware shell setup

Resolved 💬 2 comments Opened May 8, 2026 by avifinkel-slate Closed Jun 7, 2026

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 .nvmrc pinning

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:

  1. zsh starts, sources ~/.zshenv (e.g., loads nvm, runs nvm use against the current .nvmrc, prepends the right Node bin to PATH).
  2. The -c body runs and sources the snapshot, which contains a verbatim export PATH=...v22.11.0... captured at launch.
  3. The snapshot's PATH overwrites whatever .zshenv set.
  4. eval '<user command>' runs with the launch-time PATH, 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 for zsh -c.
  • chpwd hook → only fires on cd, not at shell startup, and is in .zshrc anyway.
  • precmd/preexec → don't fire in zsh -c non-interactive mode.

There is currently no first-class way to have cwd-dependent environment in Bash tool subshells.

Suggested fixes (any one would help)

  1. Source .zshenv after 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 before eval, would let users do nvm use (or equivalent) per invocation without rewriting commands.
  2. Don't bake PATH into the snapshot at launch time. Capture functions, aliases, options, and exported scalars, but let the live shell's startup files determine PATH. 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.
  3. Have PreToolUse hooks 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 has if: "Bash(...)" filtering, but ordering is the real issue.
  4. Document the snapshot mechanism and its precedence. Right now there's no indication in user-facing docs that .zshenv will lose to a captured PATH. 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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗