Breaking the Workspace Boundary: A Parent-Directory TOCTOU in Claude Code on macOS

HackerOne #3882177

CVSS 7.7

Amartya Jha

In this Security Research

No headings found on page

In this Security Research

What if the file a permission system approved isn't the file that actually gets written, and the process that swapped them never had permission to touch the target at all?

That's what we found in Claude Code, Anthropic's agentic coding tool, running on macOS.

A malicious repository could stay perfectly inside Claude Code's Bash sandbox, never once writing outside the workspace itself, and still get Claude Code's trusted, host-owned Edit tool to overwrite a file the sandbox had explicitly denied it. No outside-write approval. Zero permission denials in the logs. The file just changes.

Anthropic triaged this as High, CVSS 4.0 score 7.7. It's a time-of-check to time-of-use (TOCTOU) race on a parent directory the classic filesystem identity bug, but sitting exactly on the boundary between a sandboxed repo process and a privileged coding agent.

Now, here's how we found it and how it works.

Where This Started

We've been auditing agentic development tools at CodeAnt AI Security Research. The premise is simple: coding agents deliberately blur the line between "untrusted code the repo brought in" and "trusted actions the agent takes on your behalf." Every place those two authorities meet is a boundary and boundaries are where bugs live.

Claude Code is a good example of the design done deliberately. It separates two forms of authority during a session. Bash commands and their child processes can run inside an operating-system sandbox, while built-in tools such as Edit execute in the trusted Claude Code host process. That's useful: repository-controlled tests, build scripts, and dependencies can run with restricted filesystem access while Claude still applies approved changes to the project.

The security of that whole design rests on one property:

The file authorized by the permission system must be the same file later modified by the host-owned writer.

We pulled Claude Code 2.1.217 on macOS arm64 and did static analysis of the embedded JavaScript to see whether that property actually held. It didn't. The Edit tool authorized a pathname using point-in-time symlink resolutions, checked those resolutions again, and then performed asynchronous directory creation before reopening the original pathname for the final write. That gap was the whole bug. A sandboxed repository process could use it to atomically swap an in-workspace ancestor directory for a prepared symlink. The pathname stayed textually identical but it now resolved to an attacker-selected location outside the workspace by the time the host process consumed it.

The result was a clean cross-boundary write primitive:

sandboxed repository process
  -> cannot write the outside target directly
  -> changes only an in-workspace directory entry
  -> redirects Claude Code's host-owned Edit operation
  -> outside file is replaced without an outside-write approval
sandboxed repository process
  -> cannot write the outside target directly
  -> changes only an in-workspace directory entry
  -> redirects Claude Code's host-owned Edit operation
  -> outside file is replaced without an outside-write approval
sandboxed repository process
  -> cannot write the outside target directly
  -> changes only an in-workspace directory entry
  -> redirects Claude Code's host-owned Edit operation
  -> outside file is replaced without an outside-write approval

We reproduced the overwrite in three consecutive clean runs. Then we went one step further and showed the practical consequence: we replaced source code in a sibling project that an ordinary development reloader was watching. The reloader executed the modified source in the host context no second Claude Code approval anywhere in the chain.

To be precise about what this is and isn't: this was not an unconditional remote-code-execution primitive. The automatic-execution case needed an existing consumer a dev watcher, reloader, build service to load the overwritten file. The defensible impact on its own was arbitrary modification of attacker-selected, user-writable files outside the authorized workspace, with code execution possible when a normal downstream consumer happened to be present. We kept that distinction sharp throughout the report, and we're keeping it sharp here.

The Boundary Claude Code Was Expected to Enforce

Anthropic's documentation describes two complementary controls:

  1. Permissions decide whether Claude may invoke tools such as Bash, Read, Edit, and Write.

  2. Sandboxing restricts Bash commands and their descendants at the operating-system level.

In acceptEdits mode, file edits are automatically approved only for the working directory and configured additional directories. Paths outside that scope stay protected. On macOS, sandboxed Bash processes are constrained by Seatbelt and cannot modify arbitrary files outside the project.

Together, those controls create a clean division of responsibility:

Principal

Intended write authority

Sandboxed test, dependency, or build process

Current workspace and explicitly allowed locations

Claude Code built-in Edit tool

Files authorized by the permission system

User

Any action explicitly approved through a permission prompt

The vulnerability lived exactly where these layers met. The sandbox correctly denied the repository process. The permission layer correctly rejected a direct request to edit an outside path. The failure was that the host writer acted on a different filesystem object than the one the permission checks had examined.

A Realistic Repository-Driven Trigger

A filesystem race is only a real product vulnerability if attacker-controlled input can actually reach the vulnerable operation. So we didn't assume a repository author could just hand-pick an arbitrary Edit call. We used a workflow every coding-agent user will recognize:

  1. A developer opens an attacker-controlled repository or pull request.

  2. The developer asks Claude to run the tests and fix the failure.

  3. Claude runs the repository's normal test command inside the Bash sandbox.

  4. A repository-controlled pretest or build step launches a confined helper and waits until it's ready.

  5. The test reports a missing generated fixture at a specific path that looks like it's inside the repository.

  6. Claude follows the diagnostic and creates that fixture with its built-in file-editing path.

The repository controls the test, the diagnostic, the expected content, and the waiting helper. It never needs to predict a path the model independently chose every part of the test funnels toward the same attacker-influenced fixture path.

A simplified target looks like this:

<workspace>
<workspace>
<workspace>

Before the race, signal is an ordinary directory, so the complete path is both lexical and canonical inside the workspace. A second entry named armed is prepared as a symlink to /, but it isn't part of the target path yet.

The helper stays confined to the workspace the entire time. Its direct attempt to write the outside canary returns EPERM. The only successful operation it performs is an atomic exchange between two entries it already controls inside the project.

The Vulnerable Sequence

Static analysis of the embedded JavaScript showed the relevant order in the Edit path:

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

The actual minified implementation used internal functions, but the security-relevant sequence was equivalent:

permission-time path resolution
  -> later path-resolution comparison
  -> asynchronous recursive mkdir
  -> temporary-file creation
  -> pathname-based replacement
permission-time path resolution
  -> later path-resolution comparison
  -> asynchronous recursive mkdir
  -> temporary-file creation
  -> pathname-based replacement
permission-time path resolution
  -> later path-resolution comparison
  -> asynchronous recursive mkdir
  -> temporary-file creation
  -> pathname-based replacement

That second comparison reduces ordinary symlink-substitution risk but it's still a point-in-time check. It doesn't bind authorization to stable directory handles or inode identities. And, more importantly, it runs before recursive directory creation.

The helper used directory creation itself as a synchronization signal. When the first missing suffix directory appeared, the helper knew the final resolution comparison had already returned. It then invoked the macOS renameatx_np operation with RENAME_SWAP to atomically exchange signal and armed.

There was no unlink-and-recreate gap to lose the race in:

before swap: signal = ordinary directory, armed = symlink to /
after swap:  signal = symlink to /,        armed = ordinary directory
before swap: signal = ordinary directory, armed = symlink to /
after swap:  signal = symlink to /,        armed = ordinary directory
before swap: signal = ordinary directory, armed = symlink to /
after swap:  signal = symlink to /,        armed = ordinary directory

After the exchange, the unchanged textual target resolved outside the workspace:

<workspace>

<workspace>

<workspace>

Claude Code's host-side writer then created a temporary file and renamed it over the outside target. The sandboxed process never opened or modified that target itself it only ever flipped a directory entry it already owned.

Why Final-Component Protections Weren't Enough

The file writer did include protections against following a symlink in the final path component. That's genuinely valuable but this race operates on an ancestor directory.

Filesystem path resolution is a walk through a sequence of directory entries. Protecting only the final component does nothing to preserve the identities of the directories traversed earlier. If an ancestor changes after authorization, the same string can name a different object even when the final filename is untouched.

That distinction is the whole bug:

What remained stable

What changed

Tool name: Edit

An ancestor directory entry

Textual target pathname

The filesystem object reached by that pathname

Requested content

The destination receiving that content

Permission result

The authority exercised by the final write

The authorizer reasoned about pathname strings and their observed resolutions. The executor later traversed the pathname again. That made the check and the use refer to two different object identities.

Differential Evidence

We built the reproduction to leave no ambiguity about what boundary was being crossed. It used temporary canaries, an isolated home directory, user-only settings, a deterministic local model fixture, and no Anthropic credential or external service. The security claim only begins when normal Bash and Edit tool calls reach Claude Code's native permission and sandbox enforcement paths.

Five controls separated the vulnerability from expected behavior:

Case

Result

Security meaning

Direct Edit to an outside path

Denied

The permission boundary recognizes outside writes

Direct write by the sandboxed helper

EPERM

Seatbelt blocks the attacker principal from the target

Same lexical construction without the swap

Outside canary unchanged

The path is safe when object identity remains stable

Synchronized ancestor swap

Outside canary replaced, zero permission denials

The host writer crosses the workspace boundary

Sibling development watcher

Modified source executed

A normal downstream consumer turns integrity loss into host execution

The primary overwrite and the automatic-execution impact case passed in three consecutive clean runs. In every run:

  • the direct outside edit was denied;

  • the sandboxed helper's direct outside write failed with EPERM;

  • the benign control modified only the in-workspace file;

  • the raced edit recorded zero permission denials;

  • the outside file's content and inode changed; and

  • the development watcher produced the expected execution marker.


Impact

The primitive let a malicious repository process, dependency script, build task, or prompt-injected command running inside the supported Bash sandbox cause the trusted host process to replace an attacker-selected, user-writable file outside the workspace.

Potential consequences included:

  • modifying source code in sibling projects;

  • poisoning package metadata, build inputs, and ordinary development configuration;

  • modifying files later consumed by CI or developer tooling; and

  • obtaining host-context command execution when an existing watcher, reloader, build service, or similar consumer automatically loaded the changed file.

The capability delta was explicit. Before exploitation, the attacker principal could write only within the project and received EPERM for the outside target. After exploitation, the same principal could direct Claude Code's unsandboxed file writer to replace that target.

Anthropic rated the report High with 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
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
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

We did not claim unconditional Critical severity. Automatic execution required a suitable downstream consumer, while the underlying outside-workspace overwrite was independently demonstrated.

Root-Cause Analysis

The failure is an authorization-to-use binding problem:

authorized identity: pathname resolutions observed at time T1
consumed identity:   pathname traversal performed at time T2
attacker action:     atomically replace an ancestor between T1 and T2
authorized identity: pathname resolutions observed at time T1
consumed identity:   pathname traversal performed at time T2
attacker action:     atomically replace an ancestor between T1 and T2
authorized identity: pathname resolutions observed at time T1
consumed identity:   pathname traversal performed at time T2
attacker action:     atomically replace an ancestor between T1 and T2

The implementation tried to defend against path changes by resolving and comparing the path twice. That narrows the window; it doesn't close it. Any later asynchronous operation including recursive directory creation hands an attacker-controlled process another scheduling opportunity.

Static review also found the same check, directory-creation, and writer ordering in the built-in Write path. The submitted runtime proof and the validated security claim exercised Edit. The Write observation supports a shared plumbing concern but shouldn't be presented as a separately reproduced exploit.

Recommended Remediation

The durable fix is to bind authorization and mutation to the same filesystem objects.

  1. Open the trusted workspace directory and retain its directory descriptor.

  2. Walk each target ancestor relative to that descriptor using openatstyle operations.

  3. Open directories with O_DIRECTORY | O_NOFOLLOW and retain the verified descriptors through the entire operation.

  4. Create missing directories with mkdirat relative to a verified parent.

  5. Create the temporary file and complete the replacement with descriptor-relative operations such as openat and renameat.

  6. If any component must be reopened across an asynchronous boundary, verify the device and inode identity before continuing.

  7. Apply the same safe writer primitive to every host-side file mutation path that shares this trust boundary.

A pathname recheck immediately before the final write is useful defense in depth, but it remains raceable. The authorization must follow the object, not repeatedly rediscover it by name.

Regression tests should force an atomic ancestor exchange after permission evaluation and verify the outside canary stays unchanged. The matrix should cover existing targets, missing parent directories, Edit, Write, and any internal replacement helper that uses the same writer.

Why This Matters Beyond Claude Code

This finding is a specific instance of a pattern we keep seeing as coding agents get more capable.

1. A sandboxed process can still influence a privileged host tool

Sandboxing limits direct operating-system access. It does not remove the process's ability to shape diagnostics, file paths, tool arguments, or filesystem state consumed by the host. Every one of those transitions needs its own security review.

2. Path strings are not stable authorization identities

A pathname is a lookup recipe, not an immutable object reference. If authorization and mutation happen at different times, the implementation must preserve the checked object identities across that interval.

3. Negative controls must prove the capability delta

The strongest evidence wasn't merely that the outside file changed. It was the contrast between a direct write returning EPERM and Claude Code's host writer successfully replacing the same target. That comparison showed exactly which defended boundary was crossed.

4. Reachability matters as much as the primitive

The repository-driven test workflow explained how lower-trust content could start the helper and steer Claude toward the chosen path through ordinary developer behavior. A deterministic primitive only becomes a meaningful product vulnerability once this acquisition path is established.

5. Impact chains should stay conditional where appropriate

The outside-file overwrite was the core vulnerability. Host execution through a development watcher was a demonstrated consequence under a stated prerequisite not a reason to label every instance Critical.

The Disclosure

We reported this through Anthropic's authorized HackerOne program. The exchange was fast and substantive: Anthropic asked us to demonstrate a realistic repository-to-race trigger rather than a synthetic one, we supplied the end-to-end test-and-repair workflow the next day, and they confirmed the file-editing behavior and triaged the report High.

The macOS sandbox behaved as configured, and the permission layer rejected direct outside edits. The vulnerability appeared because the privileged file writer didn't preserve the identity of the object those controls had authorized a subtle, easy-to-miss gap on a boundary that carries a lot of trust.

Timeline

Date

Event

July 22, 2026

Report submitted to Anthropic through HackerOne (#3882177)

July 23, 2026

Anthropic requested a realistic repository-to-race trigger

July 23, 2026

End-to-end test and repair workflow supplied

July 24, 2026

Anthropic confirmed the file-editing behavior and triaged the report High

August 10, 2026

Bounty of $XXXX awarded

Editorial cutoff

Report remained triaged and private; remediation status not publicly confirmed

Are You Affected?

Step 1: Check your platform and version

This finding was reproduced on Claude Code 2.1.217, macOS arm64. The trust boundary in question a sandboxed Bash process influencing the host-owned file writer is specific to how Claude Code splits authority on macOS, where sandboxed Bash is constrained by Seatbelt.

Check your installed version:

claude --version
claude --version
claude --version

Step 2: Check if you're exercising the vulnerable flow

You're in scope of the pattern if all of these are true:

  • You run Claude Code on macOS.

  • You let Claude run repository commands inside the Bash sandbox while using the built-in Edit / Write tools.

  • You operate in acceptEdits mode (or otherwise auto-approve in-workspace edits) so file creation follows a diagnostic without a fresh prompt.

  • You open or test untrusted repositories or pull requests.

The dangerous combination is untrusted repository content plus auto-approved in-workspace edits that's what lets a repository steer Claude toward an attacker-chosen path while a confined helper flips the ancestor underneath it.

Step 3: Reduce exposure

Until a fixed build is confirmed:

  • Be deliberate about running untrusted repositories and PRs through an agent that has any auto-approve edit mode enabled.

  • Prefer explicit per-edit approval over acceptEdits when working with code you don't control.

  • Watch for sibling projects, reloaders, and build watchers running alongside your workspace they're the consumers that convert an integrity loss into host execution.

  • Update Claude Code once Anthropic ships and confirms remediation, and re-verify the fixed version before relying on it.

Note: Remediation status was not publicly confirmed at the editorial cutoff. Reconfirm the fixed version and affected releases with Anthropic before acting on this section.

Conclusion

The correct invariant here is simple to state and demanding to implement:

The object that receives the write must be the same object, reached through the same verified ancestor chain, that the permission system authorized.

That invariant closes this race and it's a stronger foundation for every filesystem operation an autonomous coding agent performs on your behalf. For agentic development environments, the broader takeaway is this: every transition from lower-trust repository activity to a host-owned tool is a security boundary. If the host checks a pathname and later follows that pathname again, an attacker may be able to change the object behind the name while every individual check still appears to pass.

References

This research is part of an ongoing effort by CodeAnt AI Security Research to audit the trust boundaries in agentic development tools and the open-source ecosystem they run on. This finding was conducted through Anthropic's authorized HackerOne program; the proof of concept used isolated temporary canaries and did not access or retain user data. More findings will be published as coordinated disclosure timelines are met.

If you build an agentic developer tool and believe a similar boundary may be at risk, we'd love to help: securityresearch@codeant.ai

[FAQ]

Frequently Asked
Questions

What did CodeAnt AI Security Research find?

What was tested and affected?

Am I affected if I run Claude Code on Linux, or without the Bash sandbox?

How does the exploit work?

Why is this rated High (7.7) rather than Critical?

[GET STARTED]

Find out what's already

exploitable in your codebase.

Find out what's already

exploitable in your codebase.

Find out what's already exploitable in your codebase.

START PENTEST

NO CC REQUIRED