September 2, 2026
The Anatomy of an AI Coding Agent Sandbox Escape
What nine days of Claude Code changelog entries taught me about trusting an agent with my shell

By Ali Süleyman TOPUZ
13 min read
- 1 The cluster, laid out
- 2 1. Worktree isolation that wasn't actually isolation
- 3 2. The hook that was supposed to catch everything, except in the background
- 4 3. Three fixes, one root cause: the permission checker doesn't parse shell the way shell parses shell
- 5 4. A command you approved that wasn't the command you read
I read changelogs the way most people skim terms of service, fast, half-attention, looking for the one line that says "breaking change" so I know whether to update today or wait. I've been doing that with Claude Code for months, because I run it with real permissions against real repositories, and a version bump can quietly change what the agent is and isn't allowed to do to my machine.
In early August I stopped skimming. I'd noticed the same shape of sentence showing up release after release: "fixed a permission-check bypass," "fixed isolation not applying to," "fixed X being able to escape Y." Once I actually lined them up by date, I realized I wasn't looking at routine bug fixes. I was looking at a security audit playing out in public, one changelog entry at a time, and five of those entries landed inside a nine-day window between August 4th and August 13th, 2026, on top of two more from three weeks earlier that turned out to be the same root cause resurfacing.
None of these were catastrophic in the sense of "someone's production database got wiped." As far as I can tell, all five were caught and closed before they were exploited in the wild that we know of. But that's exactly why I think they're worth writing about instead of ignoring. Each one is a small, specific answer to the question every person running an autonomous coding agent should be asking themselves: when I click "allow," what am I actually allowing? Reading these fixes closely taught me more about that question than any of the marketing copy around "sandboxed," "isolated," or "permission-gated" ever has.
This isn't a vulnerability disclosure. I didn't find any of these; Anthropic did, and they shipped fixes fast. It's a practitioner's teardown: what each of these five fixes actually protected against, why it mattered for how I had my own setup configured, and what I changed afterward.
The cluster, laid out
Here's the run of entries as they appear in the Claude Code changelog, condensed to what each one actually changed:
+---------+-----------+--------------------------------------------------------+
| Version | Date | What broke |
+---------+-----------+--------------------------------------------------------+
| 2.1.214 | Jul 18 | bypassPermissions mode ignored the org policy that |
| | | disables it |
| 2.1.214 | Jul 18 | Bash permission checks failed open on redirects, very |
| | | long commands, zsh subscripts, and certain help/man |
| | | invocations |
| 2.1.221 | Aug 4 | zsh [[ ]] regex conditionals could hide commands from |
| | | the permission check entirely |
| 2.1.222 | Aug 4 | worktree-isolated sessions and subagents could run |
| | | destructive git commands against the main checkout |
| 2.1.222 | Aug 4 | PreToolUse auto-allow hooks were bypassed inside |
| | | background tasks (summaries, compaction, renames) |
| 2.1.223 | Aug 6 | crafted Bash commands could hide parts of themselves |
| | | from the permission check |
| 2.1.223 | Aug 6 | tabs and invisible Unicode could pad a command so part |
| | | of it never rendered in the approval dialog |
| 2.1.223 | Aug 6 | workflow scripts could use dynamic import() to run code |
| | | outside the workflow sandbox |
+---------+-----------+--------------------------------------------------------++---------+-----------+--------------------------------------------------------+
| Version | Date | What broke |
+---------+-----------+--------------------------------------------------------+
| 2.1.214 | Jul 18 | bypassPermissions mode ignored the org policy that |
| | | disables it |
| 2.1.214 | Jul 18 | Bash permission checks failed open on redirects, very |
| | | long commands, zsh subscripts, and certain help/man |
| | | invocations |
| 2.1.221 | Aug 4 | zsh [[ ]] regex conditionals could hide commands from |
| | | the permission check entirely |
| 2.1.222 | Aug 4 | worktree-isolated sessions and subagents could run |
| | | destructive git commands against the main checkout |
| 2.1.222 | Aug 4 | PreToolUse auto-allow hooks were bypassed inside |
| | | background tasks (summaries, compaction, renames) |
| 2.1.223 | Aug 6 | crafted Bash commands could hide parts of themselves |
| | | from the permission check |
| 2.1.223 | Aug 6 | tabs and invisible Unicode could pad a command so part |
| | | of it never rendered in the approval dialog |
| 2.1.223 | Aug 6 | workflow scripts could use dynamic import() to run code |
| | | outside the workflow sandbox |
+---------+-----------+--------------------------------------------------------+Eight entries, five distinct vulnerability classes, three of them landing in a 48-hour window. That's not a coincidence. That's what it looks like when a security team pulls a thread and keeps finding more of the same sweater.
I want to walk through each of the five classes, not the changelog wording but what the wording means if you're the one who typed claude into a terminal this morning.
1. Worktree isolation that wasn't actually isolation
Claude Code's worktree mode is one of the features I use constantly: it spins up an isolated git worktree so an agent (or a fleet of subagents working in parallel) can make changes without touching my actual checkout until I decide to merge. The entire pitch of the feature is contained in one word: isolated. That's what makes it safe to let an agent run semi-autonomously on a task while I do something else.
The August 4th fix (v2.1.222) closed a gap where isolation applied to file edits but not consistently to git operations run through Bash, and not consistently across every session type, meaning a worktree-isolated session, or a subagent it spawned, could run a destructive git command that reached back into the main checkout it was supposed to be sealed off from. Think git reset --hard, git clean -fd, or a forced branch operation, issued from inside what you believed was a sandboxed workspace, landing on the repo you didn't hand over.
The part that got my attention isn't the bug itself. Isolation boundaries are hard to get airtight, especially once subagents are spawning subagents. It's that the fix note specifically says isolation "now applies to file edits and Bash in every session type." Read backwards, that's an admission that it previously didn't, uniformly. If you were running worktree sessions before August 4th and trusting the word "isolated" as a hard boundary rather than a strong default, the trust was ahead of the implementation.
What I changed: I stopped treating worktree isolation as a reason to skip reviewing what an agent actually ran. I still use it (it's genuinely useful), but I check git reflog on the main checkout after any session that touched git history, worktree or not. It costs ten seconds and it means I'm not relying on a boundary I can't independently verify held.
2. The hook that was supposed to catch everything, except in the background
PreToolUse hooks are how you bolt your own policy onto Claude Code: a script that runs before any tool call and can block, modify, or auto-allow it based on rules you write. I use one to auto-allow read-only commands and force a manual prompt for anything that writes outside a specific directory. It's the backbone of how I let the agent move fast on the boring 90% of a task while keeping a human gate on the risky 10%.
The Aug 4th fix (also v2.1.222, same release as the worktree fix) closed a gap where PreToolUse auto-allow rules were bypassed specifically inside background agent tasks (the housekeeping work Claude Code does on its own, like summarizing a long conversation, compacting context, or renaming a session). Those aren't tasks you explicitly kicked off; they're infrastructure the agent runs to keep itself functional. And that infrastructure wasn't reliably going through the same permission gate as your foreground tool calls.
This is the one that unsettled me most, honestly, because it's the kind of gap you'd never find by testing your own hooks: you'd test the tool calls you can see, not the ones the agent triggers on itself in the background. It's a reminder that "I wrote a policy and it works when I test it" and "the policy is enforced everywhere the product can invoke a tool" are two different claims, and only Anthropic's own team had visibility into the second one.
What I changed: nothing I could change directly. This one only gets fixed upstream, and it was. But it moved my mental model of hooks from "the enforcement layer" to "one enforcement layer among several I should assume can have gaps," which is really just a more honest way to think about any policy layer sitting on top of a fast-moving product.
3. Three fixes, one root cause: the permission checker doesn't parse shell the way shell parses shell
This is the one worth slowing down on, because it isn't really three bugs: it's one structural problem that surfaced three times in three weeks.
Claude Code's Bash tool has to decide, before running a command, whether that command matches your allow rules, your deny rules, or needs to prompt you. To do that, it has to parse the command (figure out what it actually does) using its own analyzer, separate from the shell that will eventually execute it. Every one of these three fixes is a case where the analyzer and the real shell disagreed about what a command meant:
+---------+--------------------------------------------------------------+
| Date | Where the permission analyzer and the real shell disagreed |
+---------+--------------------------------------------------------------+
| Jul 18 | File-descriptor redirects the analyzer didn't parse the way |
| | bash actually parses them (fail-open) |
| Jul 18 | zsh variable subscripts/modifiers inside [[ ]] treated as |
| | inert text instead of live code |
| Jul 18 | Certain help/man invocations auto-approved even when they |
| | could run unsafe options, command substitution, or backslash |
| | paths |
| Aug 4 | zsh [[ ]] regex conditionals could execute hidden commands |
| | the analyzer never saw |
| Aug 6 | Crafted commands could hide parts of themselves from the |
| | check entirely |
+---------+--------------------------------------------------------------++---------+--------------------------------------------------------------+
| Date | Where the permission analyzer and the real shell disagreed |
+---------+--------------------------------------------------------------+
| Jul 18 | File-descriptor redirects the analyzer didn't parse the way |
| | bash actually parses them (fail-open) |
| Jul 18 | zsh variable subscripts/modifiers inside [[ ]] treated as |
| | inert text instead of live code |
| Jul 18 | Certain help/man invocations auto-approved even when they |
| | could run unsafe options, command substitution, or backslash |
| | paths |
| Aug 4 | zsh [[ ]] regex conditionals could execute hidden commands |
| | the analyzer never saw |
| Aug 6 | Crafted commands could hide parts of themselves from the |
| | check entirely |
+---------+--------------------------------------------------------------+If you've ever written a regex to validate shell input, you already know why this keeps happening: shell isn't a regular language, it has quoting rules, expansion rules, and per-shell dialect differences (bash vs. zsh alone is enough to cause this), and any analyzer that isn't literally the same parser as the shell it's guarding is going to drift from reality at the edges. Anthropic closed three separate drifts in three weeks, and I'd bet money it's not the last one. Not because their team is careless, but because this is close to an unsolvable problem in the general case. The honest fix isn't "parse shell perfectly," it's "fail closed by default," and you can see that instinct in the Jul 18 note explicitly: ambiguous cases now prompt instead of running automatically.
What I changed: I audited my own allow-list. I had a couple of broad patterns in there, things like allowing any git * or any command starting with a particular binary, that I'd added for convenience months ago and never revisited. Broad allow patterns are exactly the surface these bugs live on, because the gap isn't in the specific command you meant to allow, it's in some other command your pattern happens to also match once the analyzer misparses it. I narrowed mine to specific subcommands and flags instead of wildcards.
4. A command you approved that wasn't the command you read
The Aug 6th fix (v2.1.223) is the one that's easiest to explain and hardest to forgive in principle: the approval dialog is the entire trust model for manual permission prompts. You read the command, you decide, you click allow. If the text rendered in that dialog can differ from the text that actually executes, the dialog isn't a security control anymore. It's theater.
The bug: a command padded with tabs or invisible Unicode characters could push part of itself out of what actually rendered in the dialog, or use invisible characters to make the visible portion look different from what would run. You'd approve what you saw. What ran could contain more than what you saw.
I want to be fair to the severity here: this requires the command text itself to already be attacker-influenced (through a prompt injection in a file the agent read, a malicious MCP tool response, a compromised dependency description, something upstream feeding text into the agent's plan) before it ever reaches your approval dialog. It's not a bug that lets a stranger reach into your terminal unprompted. But it's precisely the second-stage bug that turns a successful prompt injection into an actual shell compromise instead of a blocked, visible, "wait, that doesn't look right" moment. The whole point of the approval step is to be the human circuit breaker after something upstream has already gone wrong. A dialog that can be made to lie defeats the one layer that's supposed to work even when everything above it has failed.
What I changed: I stopped fully trusting the dialog text as a copy-paste-safe transcript. When a command looks even slightly off (unusual length, a weird gap, anything that doesn't read like something I'd have typed myself), I deny it and ask the agent to explain what it's trying to do in plain language first, rather than approving and investigating after.
5. A sandbox with a door built into the language runtime
The last one (also v2.1.223) is specific to workflow scripts: the automation layer where you can wire up scripted steps for the agent to run, executed inside a sandbox that's supposed to keep them from reaching outside that workflow's boundary. The fix: those scripts could use JavaScript's dynamic import() to load and run code outside the sandbox.
This is a familiar shape of bug to anyone who's built a sandboxed JS runtime: eval, Function(), and dynamic import() are the classic trio of "the language itself gives you a way out," and blocking the obvious two while missing the third is an easy, understandable gap to leave, not a sign of a sloppy sandbox design. What it underlines for me is that "runs inside a sandbox" is a claim about a specific enforcement boundary, and that boundary is only as complete as the list of escape primitives someone thought to block. A language runtime has more of those primitives than most people, myself included, could enumerate from memory.
What I changed: I stopped treating "sandboxed" as a synonym for "safe to run untrusted-origin scripts." I still use workflow scripts, but only ones I wrote or reviewed line by line. The sandbox is a mitigation for mistakes, not a reason to skip reviewing something whose origin I don't fully trust.
It's not just Claude Code: a sidebar
While I was chasing this, I ran across independent research from the team at Ona documenting a completely different set of sandbox-escape techniques against Claude Code, unrelated to the five changelog fixes above and found through their own red-teaming rather than reported by Anthropic. Two of their findings stuck with me:
First, a denylist bypass: blocking /usr/bin/npx by path doesn't stop /proc/self/root/usr/bin/npx from resolving to the exact same binary through a different path. Path-based denylists are trying to answer "is this the forbidden program" with a check ("does this string match") that has more correct answers than the list accounts for.
Second, and more interesting: when their sandbox blocked that path trick too, the dynamic linker route stayed open. Instead of executing a denied binary directly (execve, the syscall a sandbox is watching for), you can invoke ld-linux-x86-64.so.2 (the dynamic linker itself) to load that binary's code via mmap and run it in memory. No execve ever happens. If your enforcement is watching for process execution, code can run without ever executing a process in the way your monitor is watching for.
I'm including this not because it's the same bug (it isn't, and it's a different research effort entirely) but because it rhymes with everything above. Every single one of these seven or eight fixes, across two completely independent efforts, is the same story in a different costume: an enforcement layer built around detecting a pattern (a path string, a shell command's rendered text, a syscall, a Unicode string), against a system (shell, a filesystem, a language runtime) that has more than one way to express the same underlying action. That's not a Claude Code problem. That's the problem, full stop, for anyone building a sandbox around an agent that can write and execute arbitrary code. It's going to keep producing this exact shape of bug, in this product and every other one like it, for as long as the enforcement layer is pattern-matching instead of being the actual boundary.
The common thread
Laid side by side, the five fixes stop looking like a random bug list and start looking like a map of every trust boundary a coding agent has to get right:
+---------------------------+----------------------------------------------+
| Trust boundary | What failed |
+---------------------------+----------------------------------------------+
| Workspace isolation | Worktree sandbox didn't cover git ops in |
| | every session/subagent type |
| Policy enforcement points | PreToolUse hooks skipped for background, |
| | non-user-initiated tool calls |
| Input parsing | Permission analyzer's model of "what this |
| | command does" diverged from the real shell |
| UI truthfulness | Approval dialog could be made to render less |
| | than the actual command |
| Sandbox completeness | Runtime escape primitive (dynamic import) |
| | not on the blocked list |
+---------------------------+----------------------------------------------++---------------------------+----------------------------------------------+
| Trust boundary | What failed |
+---------------------------+----------------------------------------------+
| Workspace isolation | Worktree sandbox didn't cover git ops in |
| | every session/subagent type |
| Policy enforcement points | PreToolUse hooks skipped for background, |
| | non-user-initiated tool calls |
| Input parsing | Permission analyzer's model of "what this |
| | command does" diverged from the real shell |
| UI truthfulness | Approval dialog could be made to render less |
| | than the actual command |
| Sandbox completeness | Runtime escape primitive (dynamic import) |
| | not on the blocked list |
+---------------------------+----------------------------------------------+Five boundaries, five different failure modes, and this is the part I keep coming back to: every single one closed within about three weeks of each other, most within nine days. I don't read that as "this product is unsafe." I read it as what a functioning security process looks like from the outside: not zero findings, but fast findings and faster fixes, visible in public changelog text instead of buried in a private disclosure nobody outside the company ever sees. The alternative (a product with no findings in its changelog) isn't safer. It just means nobody's looking, or nobody's telling you.
What I actually changed in my own setup
If you're running Claude Code, or honestly any coding agent with shell and file access, here's the checklist I ended up with after going through all this. None of it depends on Anthropic shipping anything further. It's just how I configure and use the tool differently now.
[ ] Update promptly. All five fixes above are already shipped. If you're
on a version older than 2.1.223, you're carrying every one of them.
[ ] Audit your Bash allow-list for broad wildcard patterns (git *,
anything-starting-with-a-binary-name). Narrow to specific
subcommands/flags. Wildcards are exactly the surface these parser-
mismatch bugs exploit.
[ ] Don't treat worktree/session isolation as a reason to skip a
post-session check. A quick `git reflog` / `git status` on your real
checkout after an agent session costs seconds.
[ ] Don't fully trust the approval dialog as a verbatim transcript.
Anything unusually long, oddly spaced, or just off: deny and ask
the agent to restate its intent in plain language first.
[ ] Treat "runs in a sandbox" as a mitigation, not a green light to run
scripts of unknown origin. Review workflow/automation scripts you
didn't write yourself before letting them execute.
[ ] If you enforce policy with PreToolUse hooks, assume background/
housekeeping tool calls are a distinct code path from user-initiated
ones, and don't assume your hook logic covers both just because you
tested the foreground case.
[ ] If your org disables bypassPermissions centrally, verify it's
actually enforced at the agent-definition level too, not just at
the session level (that was its own separate fix on Jul 18).[ ] Update promptly. All five fixes above are already shipped. If you're
on a version older than 2.1.223, you're carrying every one of them.
[ ] Audit your Bash allow-list for broad wildcard patterns (git *,
anything-starting-with-a-binary-name). Narrow to specific
subcommands/flags. Wildcards are exactly the surface these parser-
mismatch bugs exploit.
[ ] Don't treat worktree/session isolation as a reason to skip a
post-session check. A quick `git reflog` / `git status` on your real
checkout after an agent session costs seconds.
[ ] Don't fully trust the approval dialog as a verbatim transcript.
Anything unusually long, oddly spaced, or just off: deny and ask
the agent to restate its intent in plain language first.
[ ] Treat "runs in a sandbox" as a mitigation, not a green light to run
scripts of unknown origin. Review workflow/automation scripts you
didn't write yourself before letting them execute.
[ ] If you enforce policy with PreToolUse hooks, assume background/
housekeeping tool calls are a distinct code path from user-initiated
ones, and don't assume your hook logic covers both just because you
tested the foreground case.
[ ] If your org disables bypassPermissions centrally, verify it's
actually enforced at the agent-definition level too, not just at
the session level (that was its own separate fix on Jul 18).None of this is exotic. It's the same posture I'd want around any tool that can touch my filesystem and my shell on my behalf: patch fast, trust boundaries but verify them occasionally, and keep a human in the loop for anything you can't fully audit after the fact.
Why I still use it
I'll say the obvious thing plainly: reading eight changelog entries about permission bypasses and sandbox escapes in three weeks is not, on its face, a great advertisement for the tool I'm writing this in favor of continuing to use. But I've come to think the changelog itself is the strongest argument for trusting the process, if not blindly trusting any single version. A team that's actively finding and closing this many distinct trust-boundary gaps, fast, and describing them accurately enough that I could reconstruct exactly what broke and why. That's a team taking the problem seriously in the way that actually matters, which is in the fixing, not in the marketing copy about how safe the sandbox is.
What I don't do anymore is treat any individual safety feature (worktree isolation, a PreToolUse hook, the approval dialog, "it's sandboxed") as a hard guarantee. I treat all of them as what they actually are: strong defaults that reduce the number of things I need to personally verify, not a reason to stop verifying entirely. That's a more honest relationship with the tool than the one I had in July, and it's the only real takeaway from this whole exercise that's going to still be true after the next version bump closes whatever gap gets found next.
Tags: Claude Code, AI Agents, Application Security, LLM Security, Sandboxing, DevSecOps, AI Coding Tools