Windows Junction Let an AI Agent Delete 48,218 Files Out of Bounds

A Claude Code agent misused os.walk, crossing Windows junctions to delete 48,218 live files and wipe the git object store.
Based on a single-source incident report, the article reconstructs how a Claude Code subagent deleted about 48,218 live project files in 103 seconds and wiped .git/objects, refs, and logs. The mechanism is documented: the deleter used os.walk(followlinks=False) to clean a mirror copy, but os.path.islink() returns False for Windows junctions, so the traversal entered the live directory trees pointed to by 614 junctions. The article explains NTFS junction vs symbolic link differences, Python 3.12's new isjunction(), os.walk's lack of a junction check, and compares shutil.rmtree, PowerShell, and git clean behavior. It also notes worktrees share the same object store and that native Windows Claude Code does not run through a sandbox, amplifying the damage. Safeguards include realpath plus an inside() check before deletion, frequent pushes, WSL2/container isolation, approval prompts, and offsite backups.
Windows Junction Let an AI Agent Delete 48,218 Files Out of Bounds
An incident report describes a disturbing scenario: a Claude Code agent deleted roughly 48,218 active project files in 103 seconds and wiped out a git object store. According to the incident log at nope.net and reporting from Cyber Security News, the incident occurred between 10:10:31 and 10:12:14 (Eastern Time). The user claims to have explicitly authorized the agent to build, fix, and test on an isolated copy—but the copy was not actually isolated.

One key caveat up front: every account traces back to a single source—the user's Reddit post, the agent's own report, and an accompanying verification report. The AI Incident Database records it as incident #1719, dated September 19, 2026, and notes that the record has not been independently verified. Whatever actually happened on that machine, the mechanism described is well-documented, real behavior: an ancient Windows filesystem feature and a Python function that does exactly what its documentation says. You can reproduce the dangerous half in a temp folder in two minutes.
The Chain of Events: How the Agent Escaped
Based on cross-referencing Cyber Security News, nope.net, and the AIID entry, the chain of events is as follows:
The agent spawned a subagent to rebuild a project mirror. The existing build script could not refresh the mirror in place, so the subagent wrote a Python deleter to clean up the old copy. The old mirror contained 7,332 real files and 614 Windows junctions pointing at live directory trees.
The deleter used os.walk(..., followlinks=False), apparently assuming the flag would stop it from descending into linked folders. The verifier found that os.path.islink() returns False for junctions. A guard only covered the immediate files under each junction root, so everything nested deeper was traversed and deleted.
The deletion log recorded 55,550 files. Subtracting the 7,332 mirror files expected to be deleted, the verifier calculated that 48,218 live files were mistakenly deleted. The .git/objects, refs, and logs directories were wiped, and git log could not find any commits. The index survived and still listed 7,221 paths, but the blobs behind it were gone. According to nope.net, the user later said in comments they hoped Windows Volume Shadow Copies or offsite backups could restore the data. No outcome has been reported. None of the reports mention the Claude Code version, model, or permission mode, and Anthropic has not responded.
A Junction Is a Door That Traversal Tools May Not See
NTFS supports three kinds of links: hard links, junctions, and symbolic links. Microsoft's documentation defines a junction as a link whose target is a directory, which can cross different local volumes on the same computer, implemented via a reparse point. A junction cannot point to a single file or a mapped network drive.
You create a junction with mklink /J, and a directory symbolic link with mklink /D. Open either one, and you see the target's files. But as far as a program is concerned, the answer to "is this a link?" depends on which function is asking. In Python, os.path.islink() answers "is this a symbolic link?"—a junction is not. Python 3.12 added os.path.isjunction(). And os.walk, in its current source code, uses islink() or DirEntry.is_symlink() to decide whether to descend into a subdirectory, with no junction check at all.
So followlinks=False lets os.walk sidestep symbolic links, but it will walk straight through a junction. This is not news on Windows: a 2014 UrBackup blog post described os.walk looping through junctions and islink returning False for them. Junctions also show up in places you would not expect—pnpm's FAQ notes that on Windows, if Developer Mode is off, junctions are used instead of symbolic links inside node_modules.
A reparse point is a low-level NTFS mechanism: a file or directory can carry a piece of metadata interpreted by a specific filesystem filter driver, which the OS calls during path resolution to change the final destination. Junctions, symbolic links, OneDrive Files On-Demand, and some deduplication and backup products are all built on reparse points. Because path resolution happens at the filesystem driver layer, many user-mode APIs still see a "real directory," and only a caller that explicitly queries the FILE_ATTRIBUTE_REPARSE_POINT attribute and reads the reparse tag can discover that it is actually a link. This is exactly the root of the mismatch between os.path.islink() and junctions: the function was historically implemented according to POSIX symbolic link semantics, while Windows junctions belong to a different class of reparse tags and are therefore judged to be ordinary directories.
The followlinks parameter of os.walk has a misleading name. "Links" here refers specifically to symbolic links; internally the function uses islink() or DirEntry.is_symlink() to decide whether to recurse, and setting the parameter to False merely avoids following symbolic links—it does not constitute a general "do not cross any link" defense. To actually intercept junctions, you need to additionally use os.path.isjunction() (Python 3.12+), read st_reparse_tag, or uniformly resolve with os.path.realpath() and perform a containment check before deleting.
Which Deletion Tools Walk Through This Door
Behavior varies widely across tools:
| Tool | Behavior when encountering a junction |
|---|---|
Python os.walk(followlinks=False) | Enters it. Uses islink(), which returns False for junctions |
Python shutil.rmtree | Since 3.8, on Windows it deletes the junction itself without deleting its contents |
PowerShell Remove-Item -Recurse | The related bug was marked fixed in PowerShell 6, but there are reports that Windows PowerShell 5.1 still follows junctions |
Git for Windows git clean | It used to delete through junctions; a 2019 fix stopped it from traversing mount points |
cmd /c rmdir /S /Q | Suggested as a deletion method that does not follow junctions |
The same mechanism already appeared once in February 2026. A user asked Claude Code to remove two git worktrees from a pnpm monorepo, and the recursive deletion (Remove-Item -Recurse -Force, and possibly Git Bash's rm -rf) followed junctions in node_modules, permanently deleting folders in the user's config files and source code. The issue was tagged data-loss and has repro, and a bot closed it as "not planned." The same mechanism, seven months earlier, triggered by a worktree cleanup.
The behavioral differences among recursive deletion tools essentially come down to how they determine whether a link boundary is being crossed. Under POSIX semantics, symbolic links and directories are two clearly distinguished objects, and rm -rf deletes the link itself without entering the target, so the vast majority of Unix tools are safe by default. Windows's compatibility layer presents junctions as ordinary directories, and only implementations that specifically recognize reparse points can properly avoid them. Python's shutil.rmtree since 3.8 has added special-case handling for junctions on Windows, deleting the link itself while preserving the target's contents—a result driven by community feedback; git clean's 2019 fix was likewise aimed at avoiding traversal of mount points.
Conversely, any code that stitches together its own traversal logic—including deletion scripts generated on the fly by AI agents—will not automatically inherit these fixes. Cross-platform tools are especially prone to stumbling on Windows because they are usually written with POSIX assumptions and tested mostly on Linux/macOS. The reliable way to judge whether a deletion path is safe is not to rely on tool names or parameters, but to first resolve (os.path.realpath), then confirm the resolved result is still within the root directory allowed for cleanup, and only then perform the deletion.
Why an "Isolated" Worktree Does Not Isolate the Disk
Many people recommend using worktrees to keep parallel agents from interfering with each other's files. But a worktree does not isolate the disk.
The git worktree documentation states that a new worktree "shares everything except the per-worktree files (such as HEAD, index, etc.)." Inside a linked worktree, $GIT_COMMON_DIR points to the main worktree's $GIT_DIR. The objects, refs, and logs lost in this incident were exactly what all worktrees of that repository share. Give an agent its own worktree and it gets an independent branch and disk files, but it still holds a path pointing to the shared object store, and git itself will not stop the process from following it.
Claude Code's own sandbox documentation acknowledges this: when the working directory is a linked worktree, the Bash sandbox "also allows writes to the main repository's shared .git directory." And that sandbox only runs on macOS, Linux, and WSL2—"on native Windows, Claude Code does not run commands through the sandbox." Its checkpoint feature does not track files changed by Bash commands, and typically does not restore subagent edits either.
The directory layout of a linked worktree is worth spelling out. When you run git worktree add, the new directory contains only a .git file with contents like gitdir: /path/to/main/.git/worktrees/<name>. The actual metadata lives under the main repository's .git/worktrees/, while the shared parts—the object store .git/objects, references .git/refs, logs .git/logs, and so on—are pointed to by $GIT_COMMON_DIR. $GIT_DIR points to the per-worktree private directory holding HEAD, index, and so on. This means a worktree provides isolation of branches and working files, not isolation of the storage layer: any process that gets the path to a worktree and follows .git can reach the object store shared by the entire repository.
This also explains why the loss in the incident was global. The index file survived as per-worktree private data and still listed 7,221 paths, but the blobs the index referenced lived in the shared object store, and that store had been wiped, so git log could not find any commits and the working tree could not be reconstructed. In other words, a worktree lets you fork logically, but you still physically share the same history.
The Real Containment Measures
In order of priority, the following measures can limit the blast radius:
Frequently push the agent's branch. Commits on a remote repository will survive a local .git being wiped. In the incident, the index survived, but it was of no help.
On Windows, run the agent inside WSL2, where Claude Code's Bash sandbox is available (off by default, enable it with /sandbox), or use a container or virtual machine for unattended tasks.
Put shell deletions behind an approval prompt, and check what your permission mode auto-approves. In the February incident, Accept Edits was on.
Teach your own scripts to perform an inside() check. Agents copy patterns from your repository. The safe approach: resolve the path before deleting it, and confirm it is still inside the folder you intend to clean up. os.path.realpath can resolve junctions on Windows since Python 3.8. Run the inside() check on every file about to be deleted, and refuse deletion when it returns False. A junction pointing to another drive will make commonpath throw an exception, so that case should also be treated as external.
Keep offsite backups. This was the user's last hope in the incident.
It is worth noting that almost everything we know about this incident comes from the agent's own report and the deletion log. If you want a record of agent behavior that does not rely on the agent's self-reporting, you need dedicated tracing and evaluation tools to provide an independent perspective.
The implementation idea for the inside() check can be made concrete as follows: first use os.path.realpath() to resolve the path to be deleted into its final real path, and resolve the cleanup root directory the same way; then use os.path.commonpath() to compute the common prefix of the two and determine whether it equals the resolved root. If equal, it is inside and can be deleted; otherwise, refuse and log a warning. Several edge cases need attention: when a junction points to another drive, commonpath throws a ValueError because of the different drive letters, and this itself should be treated as "outside"; case and trailing separator differences need normalization; and for non-existent paths, an existence check should be done first to avoid an exception interrupting the entire cleanup process.
The value of this check is that it does not rely on any particular API's knowledge of link types, but instead judges based on the actual destination after path resolution, so it holds for junctions, symbolic links, and hard-linked directory entries. Making it a shared deletion helper function and requiring agents to reuse patterns already present in the repository when writing cleanup scripts is more sustainable than reviewing every deletion after the fact.
One-line Summary
A Claude Code agent misused os.walk, crossing Windows junctions to delete 48,218 live files and wipe the git object store.
Paragraph Summary
Based on a single-source incident report, the article reconstructs how a Claude Code subagent deleted about 48,218 live project files in 103 seconds and wiped .git/objects, refs, and logs. The mechanism is documented: the deleter used os.walk(followlinks=False) to clean a mirror copy, but os.path.islink() returns False for Windows junctions, so the traversal entered the live directory trees pointed to by 614 junctions. The article explains the difference between NTFS junctions and symbolic links, that Python 3.12 only just added isjunction(), that os.walk still has no junction check, and compares the behavior of tools like shutil.rmtree, PowerShell, and git clean. In addition, worktrees do not isolate the disk and share the same object store, while on native Windows Claude Code does not run through a sandbox—together amplifying the damage. Protective measures include realpath plus an inside() check before deletion, frequent pushes, WSL2/container isolation, approval prompts, and offsite backups.
Related articles

Amphibious Transforming Drone: Beyond 100x Power Savings, What Are the Real Needs?
Is the amphibious transforming drone just a gimmick? Starting from the 100x power difference of the M4 Morphobot, explore real application scenarios and thesis topic ideas, analyzing boundary conditions where pure drones and pure ground robots both fail.

Warhound Raises $200 Million: Genetically Engineered "War Dogs" Paired with Anduril Gear
Warhound raises $200M, backed by Ben Lamm and Palmer Luckey, to genetically engineer dogs for military and law enforcement use and equip them with Anduril gear. This article analyzes its technology direction, funding logic, and ethical controversy.

Claude API Comes to Google Cloud: IAM Integration and SDK Access Guide
Anthropic hosts the full Claude API on Google Cloud with IAM-controlled permissions and unified billing. This guide covers the Vertex AI and Anthropic-hosted entry points, IAM role configuration, and SDK access.