Here is the whole bug in two lines of Python that look completely reasonable:
real = os.path.realpath(path) # CHECK: resolve and validate
if not real.startswith("/workspace/"): # confirm it's in the sandbox
raise PermissionError("outside workspace")
with open(path, "w") as f: # USE: open by the original string
f.write(data)
Your agent's file-write tool validates that the model's requested path lives inside /workspace, then writes to it. It passes every test you'll write. And it is a sandbox escape, because between the check and the use, the path can change out from under you. That gap has a name — time-of-check-to-time-of-use, TOCTOU, CWE-367 — and it is one of the most common ways an agent that "can only touch the workspace" ends up touching /etc.
The race, concretely#
realpath() follows every symlink and returns the real, canonical target as a string. You validate that string. Good. Then open(path, "w") throws the validated result away and resolves the path again, from scratch, inside the kernel. If any component of path is different the second time, you open a different file.
Who changes it? In an agent, the caller and the attacker are frequently the same process the model controls. The model writes code that, in a tight loop, replaces workspace/reports (a real directory when you checked) with a symlink to / (by the time you open). Or two of the agent's own tool calls run concurrently and interleave. Or the "path" points into a directory the model already populated with a booby-trapped symlink on a previous turn. The check sees the safe target; the open follows the swapped one; data lands in /etc/cron.d/ or overwrites a credentials file, with your server's permissions.
The tell is always the same shape: a string is validated, then re-resolved. The validated thing (the resolved path) and the used thing (a fresh resolution of the raw string) are not the same object, and nothing holds them together across the window.
This is a live class of bug, not a textbook curiosity#
Filesystem TOCTOU has been exploited for decades, but it is having a moment precisely because agents generate the path and the concurrent code that races it. In 2026 the filelock package shipped a security advisory for exactly this pattern: its SoftFileLock checked writability and then opened the lock file in a separate step, and a symlink planted in the window could redirect the lock onto an unintended target. It was fixed in 3.20.3. The same swap keeps surfacing in agent "shell" and file-write helpers that resolve a path once, decide it's allowed, and trust that decision across an open() — the write helper never re-validates the object it actually got.
If your agent's file tool looks like the snippet at the top of this piece, you have this bug. Testing won't find it, because a test never races you.
Fix 1 — hold the object, not the name#
The root cause is checking a name and using a name. Instead, open once and verify the file descriptor — the kernel's handle to the specific object you now hold, which cannot be swapped:
import os
def agent_write(path: str, data: bytes) -> None:
# Open first. O_NOFOLLOW refuses to open a symlink as the final component,
# so an attacker can't make the final name point elsewhere.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
st = os.fstat(fd) # verify the fd, never re-resolve the string
root = os.stat("/workspace")
if st.st_dev != root.st_dev: # same filesystem as the workspace root?
raise PermissionError("crossed a mount / device boundary")
os.write(fd, data)
finally:
os.close(fd)
O_NOFOLLOW closes the final-component swap, and checking the descriptor with fstat() means you validate the exact file you're about to write — not a name that can mean something different a microsecond later. It is a real improvement over realpath()-then-open().
But note the honest limitation: O_NOFOLLOW only guards the last component. A symlink swapped into an intermediate directory (/workspace/a/b where a becomes a symlink) still redirects you. To close that fully in pure userspace you have to walk the path one component at a time with os.open(..., dir_fd=parent_fd, ...) and O_NOFOLLOW on each hop — correct, but tedious and easy to get subtly wrong.
Fix 2 — make resolution atomic (the real answer)#
The clean fix is to let the kernel resolve the whole path and enforce the constraints in a single, un-raceable operation. On Linux 5.6+ that primitive is openat2(), which takes resolve flags evaluated atomically during the path walk:
RESOLVE_BENEATH— the path may not escape above the directory fd you pass (no.., no absolute reroute).RESOLVE_NO_SYMLINKS— refuse any symlink in any component.
open a directory fd for /workspace, then:
openat2(dirfd, "reports/out.txt", &how)
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS
There is no window here. Resolution is the check — the kernel walks the path with the rules applied, and if any component is a symlink or tries to climb out of /workspace, the call fails. Nothing in userspace sits between "validated" and "used" for an attacker to exploit.
You rarely call the syscall by hand. Modern stdlibs wrap it: Go 1.24's os.Root gives you traversal-resistant OpenInRoot/Create scoped beneath a directory, backed by exactly this atomic walk; Rust's cap-std offers capability-scoped file access with the same guarantee. If you're on either, delete your realpath() allow-list and open through the root object instead.
Fix 3 — stop making path validation load-bearing#
Here's the part that outlives any single CVE. If you are running model-generated code — code you did not read, produced from a prompt you don't fully control — then a userspace path check is a correctness feature, not a security boundary, no matter how atomic you make it. One logic slip and the allow-list is the only thing between the model and your host, and TOCTOU is just one of the ways that list fails.
The durable posture is to make the escape land somewhere harmless. Run the agent's code inside a boundary the code cannot see past — a Firecracker microVM like an E2B sandbox, or at minimum a mount namespace whose entire filesystem is the workspace, so there is no /etc, no credentials file, nothing on the host to redirect a write onto. Then a won race writes into a disposable box you throw away. That's the difference between an isolation boundary and a speed bump, and it's why "we validate the path" should never be the last line of your defense.
Get the path resolution atomic and put a kernel boundary around the whole thing. The first stops the accidental escape; the second means the accident doesn't cost you the host.



