DEV Community

Amartya Jha for CodeAnt AI

Posted on Originally published at codeant.ai

Escaping Claude Code's Sandbox: A TOCTOU Bug That Let Repo Code Overwrite Host Files

A parent-directory swap timed against a permission check let sandboxed repo code redirect a host-owned file write. CVSS 7.7, High.

HackerOne #3882177 · Claude Code 2.1.217, macOS arm64 · CVSS 4.0 score 7.7, High


Sandboxing runs on a simple promise: code inside it cannot reach outside it. Claude Code, Anthropic's coding agent, keeps that promise on macOS by boxing in Bash and everything it spawns, while leaving one door unlocked: a trusted editor process that applies the file changes Claude has permission to make.

We at CodeAnt AI Security Research tested whether that door could be reached from the wrong side of the sandbox. It could. A process confined entirely to the Bash sandbox got the trusted, host-owned Edit tool to overwrite a file the sandbox had already been denied direct access to. No outside-write approval appeared anywhere, and no permission check failed. The file simply changed.

Two kinds of trust sharing one filesystem

Claude Code splits authority during a session. Bash commands and their children run inside an OS-level sandbox with restricted filesystem access, so a repository's tests and builds can execute without full run of your machine. Built-in tools like Edit run in the trusted host process, the part actually allowed to touch your project.

Permissions decide whether Claude may call a tool at all. Sandboxing then restricts what Bash and its descendants can do at the OS level regardless of permission. In acceptEdits mode, edits auto-approve only inside the working directory; Seatbelt enforces that boundary for sandboxed processes, which is why a direct outside write returns EPERM.

None of that is the problem. The problem sits one layer up: the assumption that the file a permission check looked at is still the file the trusted writer touches a moment later.

A file path is a set of directions, not a destination

A path like /workspace/project/config.json isn't a pointer to one fixed object. It's a sequence of instructions: enter this directory, then this one, then open this file. Every step resolves fresh, at the moment something reads it.

Change one directory partway through that walk, without touching the final filename, and you change where the whole path ends up. Turn project into a symlink pointing elsewhere, and the same string now means something entirely different.

That's a time-of-check-to-time-of-use problem (TOCTOU for short): a gap between when something is verified and when it's used, wide enough for the underlying object to change in between. Claude Code's Edit tool had exactly that gap.

Where the two checks stopped agreeing

Static analysis of Claude Code 2.1.217's embedded JavaScript showed how Edit authorizes and writes a file, in equivalent pseudocode:

checkParentResolutions(target);
await mkdir(dirname(target), { recursive: true });
await replaceFile(target, content);
Enter fullscreen mode Exit fullscreen mode

The tool checks the target's parent directories, checks again to catch ordinary symlink swaps, creates any missing directories, then writes the file.

That second check narrows the window for simple symlink substitution but doesn't close it. It only compares path strings at one instant, never binding authorization to a stable directory handle or inode. And the recursive mkdir call, which takes real wall-clock time, runs after that last check and before the write. That's the gap.

Turning an ordinary developer workflow into the trigger

We didn't want to assume a repository could hand-pick an arbitrary Edit call, so we used a sequence any Claude Code user would recognize as normal:

  1. A developer opens a repository they don't fully control.
  2. They ask Claude to run the tests and fix whatever's failing.
  3. Claude runs the repo's own test command inside the sandbox, exactly as designed.
  4. A pretest step quietly launches a small helper process, still confined to the sandbox, and has it wait.
  5. The test fails, reporting a missing fixture at a path that looks entirely ordinary.
  6. Claude, being helpful, creates that fixture using its built-in Edit path.

The repository controls the test, the failure message, and the fixture path. It never has to guess what Claude would independently choose.

A simplified target: <workspace>/slot/signal/private/tmp/demo/outside/payload.txt. signal starts as an ordinary directory; next to it sits armed, already a symlink to /. The helper never leaves the sandbox: a direct outside write gets EPERM every time. Its only useful move: renaming two entries it already owns inside the project.

The swap itself

The helper needed to know exactly when to move. The signal is the mkdir call itself: the moment the first missing directory gets created, Claude Code's final resolution check has already run and passed.

At that instant it calls renameatx_np with RENAME_SWAP, exchanging two directory entries atomically:

before: signal = ordinary directory, armed = symlink to /
after:  signal = symlink to /, armed = ordinary directory
Enter fullscreen mode Exit fullscreen mode

The untouched, textually identical target path now resolves somewhere else entirely. Effective target: /private/tmp/demo/outside/payload.txt.

Claude Code's host-side writer, unrestricted by the sandbox, finishes exactly as it was going to: creates a temp file, renames it over the target. It has no reason to suspect anything changed. The sandboxed helper never touched the outside file itself. It only flipped an entry it already owned.

Only the last step of the path was actually guarded

Claude Code's writer does refuse to follow a symlink in the final path component, a genuine defense that stops a simpler attack cold.

It doesn't help here, because this race happens on an ancestor directory, not the final component. Guarding only the last link in the chain does nothing to guarantee the earlier links stayed the same object they were when permission was granted.

The permission system reasoned about a string and a resolution it observed once. The writer, later, walked that same string again and got a different answer. That mismatch is the entire vulnerability.

Proving it beyond a single lucky run

We built the reproduction to leave zero ambiguity about which boundary was crossed: isolated canary files, a throwaway home directory, a deterministic local model fixture, and no Anthropic credentials or live services involved.

Five variations, each changing exactly one property, separated the bug from expected behavior. Each ran three consecutive times, clean every time:

Scenario Outcome What it proves
Direct Edit call to a path outside the workspace Denied The permission layer correctly recognizes outside writes
Sandboxed helper tries writing the outside canary directly EPERM Seatbelt correctly blocks the attacker from the real target
Same directory layout, swap never triggered Canary untouched The path is safe as long as object identity doesn't change
Swap timed against the mkdir signal Canary overwritten, zero permission denials logged The host writer crossed the workspace boundary
A sibling dev project watched by an autoreloader Modified source executed An ordinary downstream tool turns the overwrite into execution

What this actually gets an attacker

With this primitive, a malicious repository, a compromised dependency, or a prompt-injected command running inside the Bash sandbox could get the trusted host process to overwrite an attacker-chosen, user-writable file anywhere outside the workspace: modifying sibling projects, tampering with build configuration, corrupting files CI reads downstream, and in some cases running arbitrary code, when something already present picks up the changed file on its own.

To be precise: this isn't an unconditional remote-code-execution bug. Getting from "overwritten" to "executed" needs an existing consumer already watching that location. On its own, the proven impact is arbitrary modification of a file outside the authorized workspace.

Anthropic rated the report High, CVSS 4.0 score 7.7:

CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Enter fullscreen mode Exit fullscreen mode

Why checking twice wasn't enough

The root cause is an authorization-to-use binding failure: the identity authorized at time T1 isn't the identity actually used at time T2. Rechecking the path narrows that gap but can't close it, since any later step that takes real time hands an attacker another window.

We also found the identical check-then-mkdir-then-write ordering in Claude Code's Write tool during static review. We're flagging the pattern, not claiming a second confirmed exploit.

Fixing it properly

The durable fix ties authorization and the eventual write to the same filesystem objects, not to a string re-resolved twice:

  1. Open the trusted workspace root once and hold its descriptor for the whole operation.
  2. Walk every ancestor relative to that descriptor using openat-style calls, never by re-resolving the full path.
  3. Open each directory with O_DIRECTORY | O_NOFOLLOW, keeping descriptors alive until the write completes.
  4. Create missing directories with mkdirat, relative to an already-verified parent.
  5. Perform the final write with openat and renameat, not plain pathname operations.
  6. If a component must be reopened across an async gap, verify its device and inode identity first.
  7. Apply the same pattern everywhere this boundary shows up: Edit, Write, and any shared helper.

A pathname recheck before the final write is still worth having as defense in depth. It just can't be the only defense.

The broader lesson for agentic tools

Sandboxing a process doesn't remove its ability to influence what a privileged process does next. It can still shape diagnostics, file paths, and filesystem state the trusted side later consumes.

A pathname is not a durable identity; it's a recipe re-executed on demand. Any system that authorizes based on one resolution and acts on a later one needs to preserve object identity across that gap, not just recheck the string.

The strongest proof here was the contrast between a write that returned EPERM and a write, moments later, from the trusted side, that succeeded against the same target.

Should you worry about this right now

Check your version first: claude --version. This finding was reproduced on Claude Code 2.1.217, macOS arm64. Confirm affected and fixed ranges directly with Anthropic.

You're in the risk zone if: you're on macOS, you let Claude run repository commands in the Bash sandbox while using the built-in Edit/Write tools, you run in acceptEdits mode, and you regularly open repositories you don't fully trust.

Until a fixed build is confirmed, prefer explicit per-edit approval over acceptEdits for code you don't control, and watch for sibling projects or build watchers running alongside your workspace, since that's what turns an overwrite into execution.

The takeaway

The invariant this bug violated is easy to state and hard to implement everywhere it needs to hold: the object that receives a write has to be the same object, reached through the same verified chain of ancestors, that the permission system actually authorized.

If a system checks a path once and follows that same path again later, an attacker who can move what's underneath the path (without touching the string itself) can walk past every check while each one still reports success.


This was originally published by CodeAnt AI's Security Research Team. Read the complete breakdown here →

Top comments (0)