On Wednesday 22 October Anthropic shipped Claude Code 2.1.309 with one changelog line that deserves more than a line: "Fixed sandboxed Bash commands on macOS and Linux running the shell's startup files outside the sandbox before each command." The sandbox is the Seatbelt profile on macOS and the bubblewrap container on Linux that auto-allow mode leans on to run shell commands without a permission prompt. The docs say what replaces the prompt in that mode is "the sandbox boundary itself." The same release stopped sandboxed commands from editing the MCP config file passed with --mcp-config. Thursday's 2.1.310 fixed Windows hooks that let a tool call through when Git Bash is missing. And two days before all that, Monday's 2.1.305 added a Linux setting the docs describe as turning off a security boundary.

Before the fix, every sandboxed command started as /bin/zsh -c "env SANDBOX_RUNTIME=1 ... /usr/bin/sandbox-exec -p <profile> /bin/zsh -c <your command>". The outer zsh is not sandboxed, and zsh reads ~/.zshenv on every start, login shell or not. So whatever your .zshenv does ran with your full access, in the project directory, before each command Claude ran. On its own that is your code and your problem. It turns into a hole the moment .zshenv reads something a sandboxed command can write, and the project directory is exactly what it can write.

I ran 2.1.308, 2.1.309 and 2.1.310 side by side on macOS 27.2 with an instrumented ZDOTDIR. On 2.1.308 my .zshenv ran twice per command: once in an outer /bin/zsh -c env SANDBOX_RUNTIME=1 ... process that could create files in my home directory, once inside the sandbox where the same write was denied. On 2.1.309 and 2.1.310 only the inner run is left. On 2.1.308 a sandboxed command wrote a one-line .env into the project and the next Bash call executed it outside the sandbox, canary file in my home directory included. On 2.1.310 the same .env ran only inside the sandbox. The --mcp-config file went from appendable to "operation not permitted" between the same two versions. One thing did not change: the shell snapshot Claude Code takes at a session's first Bash command still runs your login shell, .zshrc included, outside the sandbox, with the project as its working directory.

Two shells per command

My test .zshenv logged its process ID, whether the shell was a login shell, its command line from ps, and whether it could create a file under my home directory. This is the 2.1.308 trace for one sandboxed command, with the proxy credentials and the Seatbelt profile cut out of the command line:

.zshenv pid=969 login=n HOME_WRITE_OK     cmd=[/bin/zsh -c env SANDBOX_RUNTIME=1 TMPDIR=/tmp/claude-501 NO_PROXY=localhost,... HTTP_PROXY=http://srt.<id>:<secret>@localhost:60213 ... /usr/bin/sandbox-exec -p ...]
.zshenv pid=969 login=n HOME_WRITE_DENIED cmd=[]

Same process ID, two runs. The first is the outer zsh Claude Code spawned to interpret the wrapper string. It read .zshenv with nothing restricting it, and its argument list carried the sandbox proxy's username and password in plain text. Then sandbox-exec replaced the process with the inner /bin/zsh -c, which read .zshenv again inside the profile, where creating a file in my home directory failed with Operation not permitted, and so did ps, which is why the second line has no command. The command Claude had asked for failed too, as it should. The code that ran before it did not.

On 2.1.309 the trace for the same command is one line, the denied one. Claude Code now hands the wrapper to the operating system as an argument list instead of a string for your shell to parse, so no shell of yours starts outside the sandbox. For bash users the outer shell was bash -c, which reads only the file named in BASH_ENV, so this was mostly a zsh exposure, and .zshenv is where zsh users put PATH and environment setup precisely because it runs for every zsh, scripts included.

What a startup file could reach

The sandbox's default write list is the working directory and its subdirectories, anything added with --add-dir, and the temp directory. ~/.zshenv is outside that, so a sandboxed command could not edit the file that ran unsandboxed. What it could edit is anything that file reads. I added one line to my test .zshenv, [ -f "$PWD/.env" ] && source "$PWD/.env", the kind of per-project convenience people do put in startup files, then asked Claude for two commands: write touch ~/.sbx-canary-test/escaped-via-dotenv into .env, then echo second-command-ran.

On 2.1.308 the first command's write succeeded, which is allowed. The second command's outer shell sourced .zshenv, which sourced .env, which created the canary in my home directory with no prompt and no sandbox. The inner shell sourced the same .env a moment later and got Operation not permitted, which is the only outcome Claude Code's output showed. On 2.1.310 the first command's write succeeded and the canary never appeared.

The protected-paths list shows the authors knew about this class. Inside the write list the sandbox refuses writes to the project's .zshrc, .zshenv, .bashrc, .envrc, .npmrc, .gitconfig and .git/hooks, because a command that could edit those "could grant itself permissions, or add a hook or MCP server that Claude Code runs outside the sandbox." .env is not on the list, and the list only works if nothing unsandboxed reads the project directory between commands. Until Wednesday something did, on every command.

Something still does, once per session. At a session's first Bash command, Claude Code runs /bin/zsh -c -l with a script that sources ~/.zshrc, to snapshot your aliases and functions into ~/.claude/shell-snapshots/. That login shell runs .zshenv, .zprofile, .zshrc and .zlogin outside the sandbox with the project as its working directory, on 2.1.310 exactly as on 2.1.308. I left the .env in the project and started a new 2.1.310 session. A prompt that used no tools took no snapshot. A prompt that ran one echo did, and the canary appeared in my home directory before the first sandboxed command started. Claude Code's own description of the snapshot folder says it takes a new snapshot each time it starts so you can change a startup file and restart. It does not say a file a sandboxed command wrote yesterday runs at that restart too.

The MCP config file

Same release, second fix: "sandboxed commands and auto-accepted edits being able to modify the MCP config file passed with --mcp-config". The project's .mcp.json was already a protected path. A file you pass by path was not. I started each version with --mcp-config ./mcp.json and asked Claude to append a newline to it. On 2.1.308: append-exit=0, 19 bytes. On 2.1.310: operation not permitted: ./mcp.json, 18 bytes. An MCP server entry is a command line that Claude Code runs outside the sandbox, so a sandboxed command that can edit that file has a way out whenever the file is next read.

The setting that opens the sandbox on purpose

Monday's 2.1.305: "Added sandbox.allowNestedUserNamespaces so sandboxed commands on Linux can create user namespaces (Chromium, rootless podman); this lets them get around the sandbox's file protections." The docs do not soften it. "If you set allowNestedUserNamespaces to true, you turn off a security boundary: code from a sandboxed command can end up running outside the sandbox." And: "Don't rely on any of the sandbox's restrictions, including protected paths and credentials.files entries, to stop a malicious command. In auto-allow mode, that command can run without a permission prompt."

The guard rails are real. It is honored from ~/.claude/settings.json and managed settings only, so a repository cannot switch it on for whoever clones it, and a command that begins with unshare stays sandboxed whatever excludedCommands says. The docs also admit the setting alone is not enough for rootless Podman. The alternative they offer for Chromium is --no-sandbox on the browser side, which keeps Claude's boundary and drops the browser's. Pick which sandbox you trust more.

Hooks that let the call through

Two more lines from the same three days. 2.1.310: "Windows: Fixed PreToolUse and PermissionRequest hooks letting a tool call through when Git Bash is missing and the hook's command is only quoted strings, such as a quoted path". 2.1.309: PowerShell hooks were "not blocking when the hook's program exits with code 2" and were "failing open when Git for Windows isn't installed". A hook that fails open is a hook that approves, and the sandbox does not run on native Windows, so there the hook was the only layer.

What to do

Run claude --version. Below 2.1.309 on macOS or Linux, the per-command gap is live. Update.

Read your .zshenv, .zprofile and .zshrc for anything that reads the current directory or $TMPDIR: a .env, an .nvmrc, a .tool-versions, a local virtualenv activation. The snapshot taken at a session's first Bash command still runs those outside the sandbox with the project as its working directory. Claude Code sets CLAUDECODE=1 in that shell's environment, so [ -z "$CLAUDECODE" ] && [ -f "$PWD/.env" ] && source "$PWD/.env" keeps the convenience for your terminal and takes it away from Claude's.

If you pass --mcp-config on a version below 2.1.309, keep that file outside the project directory.

On Linux, leave allowNestedUserNamespaces off unless you have read the docs' sentence and agreed with it. Managed settings can pin it to false.

On Linux with zsh as your login shell, check that a sandboxed true returns 0. Issue 104912, filed on Friday, reports every sandboxed command exiting 126 with empty output from 2.1.302 through 2.1.309, because the inner script's exec 3<&- is fatal in zsh and harmless in bash. I have no Linux box and did not reproduce it. The report says SHELL=/bin/bash claude works around it.

On Windows below 2.1.310, a PreToolUse hook on a machine without Git Bash is decoration.