Anyone could publish that package
On August 28 someone opened a pull request against a popular OpenAPI code generator, wrote a two-word comment on it, and the project's own release workflow published ten poisoned versions to npm with valid provenance attestations. The attacker never held a credential. The first wave carried no install script at all, hiding its payload where node-gyp evaluates Python. The whole window lasted three hours and eleven minutes, and Bromure Agentic Coding's age gate, on by default at two days, beats that without knowing anything about the attack.
Someone opened a pull request from a fork and left a two-word comment on it. The project's release pipeline read the comment, checked out their code, ran it, and signed what came out. They stole nothing to get there.
@7nohe/openapi-react-query-codegen turns an OpenAPI schema into TanStack Query
hooks. You point it at a spec and it writes the client code for you. Aikido
counts more than 150,000 downloads a week. You add a package like this because
it saves you an afternoon, and your coding agent installs it without asking
twice.
On August 28 it started shipping a credential stealer.
Aikido Security published the analysis that day, and SafeDep published its own with the workflow mechanism and a minute-by-minute timeline. The malware names itself in its own strings: Trinitite. Both teams place its tradecraft in the Mini Shai-Hulud family, the self-replicating npm worm that has been working through maintainer accounts all year.
Its route onto npm is what makes it worth reading.
The release pipeline took an instruction from a stranger
The project's release.yml had an issue_comment trigger. That is a GitHub
Actions event which fires when someone comments on an issue or a pull request,
and it is a common convenience: maintainers wire it up so they can ship a
release by typing a word instead of clicking through a UI.
This one had no author-association gate. GitHub tells a workflow whether the person who wrote the comment is the repository owner, a member, a past contributor or a complete stranger, and the workflow has to look. This one did not.
SafeDep traced the sequence. Open a pull request from a fork. Comment
npm publish on it. The pipeline wakes up, checks out the code in the
attacker's fork, and runs pnpm install, which executes the attacker's
preinstall hook on the project's own runner. That job carried
id-token: write, enough to mint an npm OIDC trusted-publishing token.
Ten malicious versions went out, each one carrying a valid provenance attestation, because the project's own pipeline built and published every one of them. Aikido's line is the one to keep: "when the workflow itself is compromised, that certificate becomes an unreliable trust signal."
Check the provenance on a package and you learn one thing: whether it came out of that repository's pipeline. Here it did. Nobody forged anything, and you can hold a correct signature over malware without either fact looking wrong. In May we watched the same outcome arrive through a stolen OIDC token. This time the attacker skipped the theft.
The first wave had no install script to find
Scan those first eight versions for dangerous install hooks and you come back
empty. Their package.json files declare nothing that runs.
The payload was in binding.gyp, the build description file that node-gyp
uses to compile native modules. Aikido explains the trick: "When
npm install processes a package containing a binding.gyp, it invokes
node-gyp to compile the native module. node-gyp evaluates the conditions
field in the file using Python, which means arbitrary Python expressions can be
placed there and they will be executed during install, even without any
preinstall script declared in package.json."
So the attacker wrote a Python expression into a build file. It walks Python's
internal subclass tree until it reaches catch_warnings, uses that class to get
at __builtins__, imports os from there, and calls os.system() on a
JavaScript file shipped in the tarball. SafeDep notes that the traversal is
written in Unicode escapes, so a grep for os.system also comes back empty.
The JavaScript underneath unwraps three layers, XOR then AES-128-GCM then a
commercial obfuscator, before pulling Bun 1.4.0 down from GitHub into a
temporary directory prefixed trinnyyyy- to run the real payload.
Nineteen minutes later the attacker shipped a second wave with preinstall
added back to package.json as a fallback. SafeDep dates that wave to less
than one minute after the first public security report. Whoever ran this was
reading the disclosures and shipping against them.
Three hours and eleven minutes
SafeDep's timeline runs in UTC. The last clean release, 3.0.2, went out on August 11. The first wave landed on August 28 at 20:00 and finished at 20:02. The second ran 20:19 to 20:21. npm pulled all ten versions at about 23:11.
That leaves an exposure window of three hours and eleven minutes. A project on
^3.0.0, the ordinary caret range npm writes for you, resolved straight into
it. If you ran a fresh install inside that window, or your CI did, or your
coding agent did, you got the worm.
Once it ran, it swept the home directory with more than 150 glob patterns: SSH
private keys, .env files, ~/.docker/config.json, AWS, Azure and GCP
credentials, npm, PyPI and RubyGems tokens, Kubernetes service account tokens,
cryptocurrency wallets. On the same list sat ~/.claude.json, ~/.claude/
and ~/.claude/mcp.json, the coding agent's own configuration and the
addresses of every MCP server it talks to.
In repositories it could reach it wrote a .claude/settings.json carrying a
SessionStart hook that runs setup.mjs every time a developer opens the
project in Claude Code. With the registry tokens it had just collected, it
republished itself into every package its victim maintained on npm, PyPI and
RubyGems. That is the worm part: each developer it landed on became a candidate
for the next release.
Two days beats three hours
Now put a Bromure Agentic Coding profile in the path.
Bromure runs each profile as its own virtual machine on Apple Silicon, and every package the agent fetches (npm, PyPI, Cargo, RubyGems, Maven, NuGet, Go modules, Packagist) passes through a proxy on the Mac side of the hypervisor before a single byte reaches the guest.
The first thing that proxy checks is how old the version is. Supply Chain →
Age gate is on by default, with a two-day minimum. Floating references
(latest, caret ranges, tilde ranges) resolve without comment to the newest
version older than the cutoff. Pin to something too fresh and you get a 451 with
a clear error.
Set that against a hostile window of three hours and eleven minutes. Run
npm install at 20:30 UTC on August 28, mid-attack, with ^3.0.0 in your
manifest, and your agent resolves to 3.0.2 from August 11. It never learns
that 3.0.3 exists.
The gate got there without a signature, a reputation score or an advisory. It read a publication date. That matters here, because the second wave shipped inside a minute of the first public warning, and any defence that waits for a report has entered the race late.
What the sweep finds when it runs anyway
Suppose you turned the gate off for a package, or the version had aged past the cutoff before anyone caught it. The payload still has to do four things, and in a Bromure profile every one of them meets a control that lives on the Mac, where the guest cannot reach it.
It has to run. Supply Chain → Strip install scripts removes
preinstall, install, postinstall and prepare from npm tarballs on the
fly, rewriting the tarball and updating the registry metadata hash so npm's own
verification still passes. That is the second wave gone. You can stack more
checks above it: OSV vulnerability lookups, and package filtering through
socket.dev, whose "block compromised packages" check covers this class of
thing (rogue install scripts, malware), or Delpi as a replacement registry.
None of them ask whether an attestation exists, which matters when the
attestation is real.
It has to find something. The 150-glob sweep runs against /home/ubuntu in
a virtual machine, not against your Mac's home directory. Folders shares are
explicit and capped at eight. The credentials it does find are decoys: tokens in
the guest are brm_… placeholders that the host proxy swaps for the real values
on the wire, so the real material never enters the VM's address space. Private SSH
keys are not in the guest at all: a
per-profile ssh-agent on the host does the signing, and ~/.docker/config.json
holds fake base64. The kubeconfig is synthetic, with throwaway client
certificates. AWS requests are re-signed host-side, so anything that skips the
proxy comes back InvalidSignatureException rather than authenticated. Turn on
Require approval to use for a credential and each substitution raises a
dialog on your Mac with a time-bounded grant.
It has to phone out. Guardrails → Outbound connections is a pf-style
ruleset: an action, a protocol (tcp, udp, web or any), a hostname or an
IPv4 CIDR, ports, and for web the HTTP methods you permit. Rules match top to
bottom, first match wins, and setting Unmatched traffic to Deny turns the
list into an allowlist. The same policy is evaluated twice by independent
layers: the virtual switch, by destination IP and DNS-snooped hostname across
every protocol, and the host proxy, by TLS SNI and method. Fetching a Bun
binary and pushing stolen secrets into a dead-drop repository both need a
destination, and destinations have to be on the list.
It has to publish. Guardrails → GitHub in Read-only treats
git-receive-pack as a write and blocks REST writes, returning a hard 403 the
agent reads as an ordinary API failure. No poisoned commit, no dead drop, no
onward release. The same modes cover GitLab, Bitbucket, Kubernetes, AWS,
DigitalOcean and container registries.
The SessionStart hook it meant to leave behind for next time sits in a home
directory that Erase home… wipes, under Resources → Storage.
Reset to base… does the same for anything written outside it. Every decision
along the way, each destination allowed or denied, each package verdict, each
credential swap, lands in the Security Log window on the host, where guest
code cannot edit it because guest code cannot reach it.
Why this keeps happening
Trusted publishing and provenance are worth having. They shut down the theft of a maintainer's npm token, which was the dominant attack a year ago. This week's compromise went around them without touching a token: a stranger could reach the release process, so the pipeline signed a truthful attestation over hostile code.
You cannot audit your way out of that, because the workflow that fails next will belong to someone else. What you can do is stop being the first machine to run a new version of anything.
Open a profile, look at Supply Chain, and check that the age gate is on. It should be, at two days, without your having touched it. Then spend two more minutes: OSV vulnerability check on, socket.dev or Delpi selected if you have a key, Strip install scripts on. In Guardrails, set Unmatched traffic to Deny with a short allow list, and put GitHub on Read-only for any profile that is not shipping. In Credentials, turn on Require approval to use for anything that can spend money.
Then let the agent install what it needs. It will get 3.0.2. Install Bromure Agentic Coding and leave the age gate alone.