4 Ways a Shell Variable Slipped Past Claude Code's Permission Checks
Book: LLM Observability Pocket Guide Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub Your settings file

Your settings file says "deny": ["Bash(rm *)"]. You read that as "the agent can't run rm". Claude Code reads it as "a command whose text matches rm * doesn't run". Those are two different promises, and the gap between them is the shell.
Between October 3 and October 9, Claude Code 's official CHANGELOG fixed four cases where a shell variable got past a permission check. In two, a deny or ask rule missed the command. In the other two, the check that auto-approves a command let one through that should have prompted. None of them is exotic: each one is ordinary shell behaviour that a text matcher has to predict before the shell runs the line.
The changelog doesn't say who found these, whether anyone hit them in the wild, or how the checker went wrong inside. I won't guess at Claude Code's internals. What you can check is what the shell does with each kind of line, and that is what decides whether your own rules hold.
The same week brought neighbours too. 2.1.290 also fixed "a deny or ask rule missing a command or path whose name came from a variable set as a prefix on declare, typeset, export or readonly ", and read-only commands like rg being auto-approved when "the shell would still expand" their arguments as wildcards.
The permissions docs are direct about what a Bash rule is. It "matches the command text Claude writes, after Claude Code splits compound commands and strips wrappers". The same page says a deny or ask rule "isn't a security boundary around the program", and shows Bash(rm *) stopping rm -rf build/ but not /bin/rm -rf build/ or bash -c 'rm -rf build/'.
The intended behaviour for variables is spelled out as well: "A deny or ask rule matches past any leading assignment, so Bash(rm *) in deny still matches FOO=bar rm -rf tmp/." The two 2.1.289 fixes are cases where that promise didn't hold under sandbox auto-allow, the mode where Claude Code "approves a command automatically, with no prompt, when the command runs inside the sandbox".
That mode is where a deny rule matters most. By default, sandboxed commands can write to the current working directory. The sandbox has nothing against rm -rf build inside your project. The deny rule is the only thing that says no.
The trouble is the order. Claude Code reads the text first. Then a shell takes that text and expands variables, splits words and looks up the command. A rule holds only if the checker predicted all of that correctly, for the shell that will actually run the line.
Fixes one and two are about two shapes that look alike and behave differently. The bash manual, under SIMPLE COMMAND EXPANSION, gives the rule. With a command on the line, "the variables are added to the environment of the executed command and do not affect the current shell environment". With no command name, "the variable assignments affect the current shell environment."
You can see both with harmless commands. I haven't run these here; the comments say what the manuals say should happen.
# Prefix: X goes into printenv's environment bash -c 'X=1 printenv X' # prints 1 # Bare assignment: X stays a shell variable bash -c 'X=1; printenv X' # prints nothing # Prefix with an expanded value bash -c 'D="$HOME" printenv D' # prints your home dir # Arguments expand before the prefix applies bash -c 'D="$HOME" echo "[$D]"' # prints [] Enter fullscreen mode Exit fullscreen mode The last line is the surprising one. In bash, the words after the command expand before the assignment text does, so "$D" is still empty when echo gets it. The value in TZ="$HOME" isn't known until the shell runs, and where it lands depends on the position of every word on the line.
A bare assignment matters for the opposite reason: it lasts. X=1; rm -rf build is two statements. The first one has no command, so it changes the shell for everything after it on that line. The changelog doesn't give an example for fix two, so take that line as one shape that fits its wording, not as the exact case Anthropic fixed. The docs list the dangerous version of this idea in the read-only rules: a command that "sets, unsets, or loops over certain special shell variables, such as PATH or IFS, prompts".
Claude Code doesn't run your commands in an abstract shell. At session start it "sources ~/.zshrc, ~/.bashrc, or ~/.profile depending on your shell", and on a Mac that shell is usually zsh: Apple made it the default for new accounts starting with macOS 10.15.
zsh has variable names that mean nothing special to bash. The clearest one is path. The zsh manual describes it as "an array (colon-separated list) of directories to search for commands", tied to PATH, where "the lowercase form is an array and the uppercase form is a scalar".
# bash: path is just a lowercase array, PATH untouched bash -c 'path=(/nowhere); command -v ls || echo no-ls' # zsh: path IS PATH, so ls can no longer be found zsh -c 'path=(/nowhere); command -v ls || echo no-ls' Enter fullscreen mode Exit fullscreen mode Per the manuals, bash still finds ls and zsh prints no-ls. Picture a checker that reasons in bash. It sees a lowercase array assignment and a read-only command, and finds nothing to ask about. Under zsh, the same text just changed which program ls means. The docs already make PATH writes prompt. The 2.1.290 fix is about names like this one, where zsh reads them differently. The changelog doesn't list which names it covered; path is just the one the zsh manual documents.
Bash 5.0, announced on January 7, 2019, added "BASH_ARGV0: a new variable that expands to $0 and sets $0 on assignment." The bash manual puts it the same way: "Assigning a value to BASH_ARGV0 sets $0 to the same value."
So a line can name its program without ever writing that program's name in the command position:
# needs bash 5.0 or later: check with bash --version bash -c 'BASH_ARGV0=echo; "$0" ran as echo' Enter fullscreen mode Exit fullscreen mode Per the manual, $0 becomes echo and the second statement runs echo ran as echo. If bash --version shows anything older than 5.0, BASH_ARGV0 is an ordinary variable there, $0 keeps the shell's own name, and the line fails instead of echoing. Swap echo for anything else and the text in the command position still says "$0". A matcher has to know that this one variable writes to another one, in bash and only from 5.0 on. The 2.1.296 line says Claude Code now asks for approval on "some commands" of this shape, which is the right default for a line whose program the text doesn't name.
Check claude --version. Every fix above is in 2.1.296 or later. Then change how you think about the rules themselves.
A Bash deny rule is a filter on the text the model usually writes. The docs say so, and these four fixes show what it costs to ask a text filter to be a wall: every one taught the checker one more shell rule. Expect more. For a limit that has to hold, add a layer that doesn't read text. The sandbox is enforced by the OS ( Seatbelt on macOS, bubblewrap on Linux and WSL2 ) on the files and hosts a shell command can reach, however the command is spelled. It covers shell commands only: Claude's file tools, MCP servers and hooks run outside it and go through the normal permission rules.
{ "permissions": { "deny": [ "Bash(rm *)", "Bash(git push *)" ] }, "sandbox": { "enabled": true, "autoAllowBashIfSandboxed": false, "allowUnsandboxedCommands": false, "filesystem": { "denyRead": [ "~/.ssh", "~/.aws" ] } } } Enter fullscreen mode Exit fullscreen mode autoAllowBashIfSandboxed defaults to true. Setting it to false brings back the regular prompts for sandboxed commands, so a deny or ask rule the matcher misses under auto-allow still ends in a prompt. That covers the shape of the two 2.1.289 fixes. It doesn't change the separate check that approves commands it judges safe, where fixes three and four lived; for those, only updating helps. You pay for it in prompts: more of them, and fewer silent approvals.
allowUnsandboxedCommands: false closes the escape hatch. Per the docs, Claude Code then ignores dangerouslyDisableSandbox, "so when a command fails under the sandbox, Claude can't retry it unsandboxed".
denyRead is a sandbox filesystem setting. By default a sandboxed command can read "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials ", and this closes those two paths for every process the command starts. If sandboxed commands push to git over SSH, this hides your key files from them too. Keep the deny rules as well. They still catch the plain rm -rf build the model writes most days.
More prompts only help if you read them. That is where Hermes IDE fits, and I build it, so weigh this with that in mind. Hermes runs Claude Code in its own real terminal and shows each session's status, including "needs approval". The attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I on Windows and Linux) jumps to the one that has waited longest. You answer in the agent's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own, so a prompt that 2.1.296 now raises stays a question for a person.
The Review Desk (⌘G) in By turn view: two turns of a Claude Code task in orders-api, turn 1's diff of src/db.ts, the changed files and a commit message drafted from the turns.
After the fact, every agent turn is saved, and the Review Desk shows changes by turn with risk flags. A deny rule decides before the command runs. A per-turn diff tells you what a command actually changed, whatever its text said.
If what you want is a record of what an agent actually ran, my LLM Observability Pocket Guide is about that: picking the tracing and eval tools that tell you what an LLM system did, so your answer to "what did the agent run?" isn't the settings file.
Hermes IDE is free to download for macOS, Windows and Linux, source-available under BSL 1.1, and not affiliated with Anthropic. If it helps, a star on GitHub helps other people find it.