DEV Community

Cover image for Until Claude Code 2.1.309, Your ~/.zshenv Ran Outside the Sandbox Before Each Command
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Until Claude Code 2.1.309, Your ~/.zshenv Ran Outside the Sandbox Before Each Command


You turned on the sandbox. Every npm test and git status the agent ran was supposed to sit inside an OS-enforced box: writes limited to the project, network only through an allowlist. And then, before each of those commands, a few lines of your own shell config ran on the other side of the wall.

That is the first bug fix in Claude Code 2.1.309, released October 22, 2026: "Fixed sandboxed Bash commands on macOS and Linux running the shell's startup files outside the sandbox before each command."

One line in a list of 62 fixes. It is worth a closer look, because the reason it could happen at all is old shell behavior that most of us never think about. Any tool that wraps your shell in a sandbox runs into it.

What the sandbox is supposed to cover

The Claude Code Bash sandbox is off by default. You turn it on with /sandbox or sandbox.enabled: true. On macOS it uses Apple's Seatbelt framework. On Linux and WSL2 it uses bubblewrap. Writes are limited to the working directory, a per-user temp directory and folders you add. Network traffic goes through a local proxy that checks each host against your allowed domains, a list that starts empty.

The docs also say the shell commands Claude runs "start inside its boundary, and so do the processes they start." Anything that runs before that boundary exists is outside it.

Which code runs before your command is up to your shell.

Your shell runs code you didn't type

Claude Code runs Bash tool commands in bash or zsh. The environment variable docs say auto-detection uses your $SHELL when it points to one of those two. If your login shell is zsh, your agent's commands run in zsh.

Here is the order zsh reads its files, from the zsh manual:

  1. /etc/zshenv, which "cannot be overridden"
  2. $ZDOTDIR/.zshenv (usually ~/.zshenv)
  3. .zprofile, only for login shells
  4. .zshrc, only for interactive shells
  5. .zlogin, only for login shells

Steps 1 and 2 have no condition. A throwaway zsh -c 'git status' is neither a login shell nor interactive, and it still reads ~/.zshenv. The manual says it plainly about the system file: it "is run for all instances of zsh". From the command line, the way out is zsh -f, which skips everything after /etc/zshenv.

Bash has a different hook with the same effect. Per the bash manual, a non-interactive bash "looks for the variable BASH_ENV in the environment" and runs the file it names. If you, or a tool you installed, exported BASH_ENV, every bash -c runs that file first.

sh is the quiet one. Bash invoked as sh reads $ENV only when it is interactive, so sh -c runs no startup file at all.

A shell process opening a door in a sandbox wall, with a small config file slipping through before the door shuts
The shell reads its startup files the moment it starts. Whether that is inside the box depends on what started first.

Which side of the wall the shell starts on

Any sandbox that wraps a shell command has to pick an order. Here are the two shapes, with a made-up sandbox-run standing in for the real wrapper (Seatbelt or bubblewrap):

# Shell first: zsh reads ~/.zshenv, THEN the
# sandbox starts around the command.
zsh -c 'sandbox-run -- npm test'

# Sandbox first: the box exists before zsh
# starts, so ~/.zshenv runs inside it.
sandbox-run -- zsh -c 'npm test'
Enter fullscreen mode Exit fullscreen mode

In the first shape, ~/.zshenv runs with your full user rights: it can write anywhere you can and open network connections no proxy sees. Only npm test is confined.

The changelog doesn't say how Claude Code's wrapper was built before 2.1.309, so I won't guess at its internals. What it does say is the outcome: on macOS and Linux, the startup files ran outside the sandbox, before each command. From 2.1.309, they no longer run outside it.

How bad that was depends on what is in your files. For most people ~/.zshenv sets PATH and a couple of variables, and nothing about that is dangerous. It matters when the file does more: fetches a token from a CLI, calls a version manager that checks the network, writes a cache, or sources something from a folder you don't control. All of that ran unconfined, once per command, with the agent choosing when commands run.

There is a related fix in 2.1.304 (October 19): unsandboxed commands in Claude Desktop's Code tab weren't seeing variables exported in startup files such as ~/.zshrc. Agents depend on those files to get a working PATH, which is why a wrapper can't just skip them.

See it on your own machine

Add one line to the top of ~/.zshenv. It appends a timestamp, the shell's PID and the first 50 characters of the -c string (zsh keeps it in ZSH_EXECUTION_STRING) to a log. Errors are thrown away so a failed write never breaks a command.

# TEMPORARY trace, delete when done
{ print -r -- "$(date +%T) $$ ${ZSH_EXECUTION_STRING[1,50]}" \
    >> ~/zshenv-trace.log } 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

Check that it fires for a non-interactive shell, and that -f skips it:

zsh -c 'true'
zsh -f -c 'true'
cat ~/zshenv-trace.log
Enter fullscreen mode Exit fullscreen mode

You should see one new line, from the first command. Now start a Claude Code session, ask it to run your tests, look around the repo and check git status. Then count:

wc -l ~/zshenv-trace.log
Enter fullscreen mode Exit fullscreen mode

Each line is one time your ~/.zshenv ran. The number depends on how many commands the agent ran and what Claude Code starts for itself, so I'm not going to give you one. Run it and see what you get.

One thing to read in the result: the sandbox only allows writes to the project, the temp directory and folders you add, so a shell started inside it can't append to a file in your home folder. On 2.1.309 or later with the sandbox on, sandboxed commands can't write to your home folder (unless you added it as a write path), so their lines won't show up in the log. That gap is a sign the file no longer runs outside the box. Any line that does appear came from a shell started outside the sandbox.

For bash, the same check uses BASH_ENV:

printf 'echo "BASH_ENV ran in $$" >&2\n' > /tmp/env-demo
BASH_ENV=/tmp/env-demo bash -c 'echo hi'
ENV=/tmp/env-demo sh -c 'echo hi'
Enter fullscreen mode Exit fullscreen mode

The bash line runs the file before echo hi. The sh line doesn't, because $ENV is for interactive shells only. Delete the trace line from ~/.zshenv when you are done.

Audit what your startup files actually do

Updating closes this hole. It doesn't change the fact that these files run for every agent command, inside the box or not. So read them:

grep -nE 'curl|wget|eval|source|TOKEN|KEY|SECRET' \
  ~/.zshenv ~/.zprofile ~/.zshrc ~/.bashrc 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

What to look for, and where it belongs:

  • Secrets exported as variables. A token exported in ~/.zshenv is re-exported by every shell the agent starts, deny list or not. Move it to ~/.zshrc, or out of your shell config entirely. The sandbox docs also say sandboxed commands inherit Claude Code's environment, "including any secrets in its environment." For those, deny the ones an agent doesn't need with sandbox.credentials.envVars and "mode": "deny", which unsets them before each sandboxed command.
  • Network calls. A curl, a plugin manager update check, or a gh auth token lookup has no reason to run for every non-interactive shell. Move it to ~/.zshrc, which zsh -c doesn't read.
  • eval "$(some-tool init)" lines. These run a program and execute whatever it prints. If an agent command doesn't need the tool, move the line to ~/.zshrc.
  • PATH and plain exports. These are what ~/.zshenv is for. Keep them there.

Then update. claude --version tells you where you are, and claude update gets you current. If you're below 2.1.309, you don't have the fix.

Where Hermes fits

I build Hermes IDE, so weigh this with that in mind. Hermes runs Claude Code, Codex and other agents each in a real terminal, running the agent's own interface. The sandbox in this post is Claude Code's, configured in Claude Code's settings, and it works the same in Hermes as in any terminal.

Where it helps here is small. The ⌘N agent picker shows which agents are installed and the version of each, so an old Claude Code is easy to spot before you start a task with it.

The New task sheet's agent picker listing Claude Code as installed with its version number, Codex installed, and other agents marked Not installed
The ⌘N New task sheet with its agent picker open: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), and the other supported agents marked Not installed.

That build predates the fix, which is the point of checking. Hermes is source-available under BSL 1.1 and runs on macOS, Windows and Linux.


If this was useful

Any agent harness can hit the same gap: the guardrail wraps the command, and the setup around it runs with your full rights. My AI Agents Pocket Guide covers where those boundaries sit in an agent loop, and how to check what really runs inside them.

You can download Hermes IDE or read the source on GitHub.

AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs

Top comments (0)