The old name could still publish
On October 13, Bloom Security disclosed SlugFest: npm's trusted publishing matched a GitHub repository by its owner/name string, so anyone who claimed a renamed or deleted repository's old name could publish new versions of packages they didn't own, with no stolen credentials. Bloom found more than 1,500 exposed packages with over 10 million downloads, and points at AI agents as the installers that pick up a new version with nobody watching. In Bromure Agentic Coding, a version that young doesn't exist for the agent, the install runs in a VM holding stand-ins, and the rename that opens the hole waits for a click.
npm trusted publishing remembered the repository's name and forgot which repository it was. When the maintainer renamed it, the next person to create a repository at that name could publish.
On Monday, October 13, Golan Myers of Bloom Security published SlugFest, a flaw in npm's trusted publishing. The Hacker News covered it the same day. Myers explains it with a lock: "It is like a lock that checks the address on the door rather than who owns the house; when the owner moves out, whoever moves in gets the key."
Bloom counted more than 1,500 exposed packages, downloaded more than 10 million times in total and more than a million times a month. The attack needs "no insider knowledge, no credential theft, and no race condition." Bloom has not named the packages: "We are not releasing a list of affected packages as the issue is still prevalent."
Bloom also names who installs these packages now. The average coding agent, by its count, installs 70 packages a month, and a developer machine about 350. In Bloom's words, a malicious version published through a reclaimed name "gets pulled in and runs with no human-in-the-loop."
How trusted publishing checked the repository
npm opened trusted publishing to everyone in July 2025. Instead of
storing an npm token in CI, a maintainer runs npm trust github --allow-publish and tells npm which GitHub repository and workflow file may
publish the package. At publish time, GitHub Actions mints a short-lived OIDC
token describing the workflow run, npm checks it against the binding, and
hands back a publish credential. No long-lived secret exists to steal.
The binding stored the repository as a slug, owner/repo, plus the workflow
filename. It did not store the numeric repository_id and
repository_owner_id that GitHub assigns for life. npm recorded those only
after the first successful publish. Until then, any repository that answered
to the right name and ran the right workflow file matched.
Names on GitHub get freed. A maintainer renames a repository, transfers it to an organization, deletes it, or renames their account. The old slug becomes available, and someone else can register it. Bloom's steps:
- Create a repository at the freed slug. GitHub gives it a new numeric ID.
- Add a workflow with the filename from the binding and
id-token: writepermission, and run it. - GitHub mints an OIDC token whose repository claim is the old slug.
- npm's exchange endpoint matches slug and filename and issues a publish credential. The numeric IDs are in the token, but npm had nothing to compare them with.
- Publish a new version of a package you don't own, with its name, its download count and every project that depends on it.
Bloom ran the chain end to end against an npm scope and repositories it controls, publishing from a second organization where only the slug matched. GitLab's trusted publishing had the same gap.
The resulting release is also signed. Trusted publishing from a public repository generates a provenance attestation, and this one would name the workflow run in the attacker's repository under the slug the maintainer configured. In August, a two-word comment on a pull request got a project's own release pipeline to publish ten poisoned versions with valid provenance. SlugFest doesn't need the victim's pipeline at all.
Four months to a fix
Bloom reported the flaw through GitHub's HackerOne program on June 6. GitHub validated it on June 9. The last substantive reply came on July 8, saying the team was "working on fixing the issue and determining its risk and severity." Six follow-ups and a mediation request to HackerOne went unanswered.
GitHub's changelog of October
2
carries the fix. A trusted publishing binding that hasn't published within 48
hours of its creation now expires and can no longer authorize a publish.
After the first successful publish, the binding is tied to the repository's
numeric identity. Changing the repository requires a new binding with a new
48-hour window. npm also rejects tokens minted by issue_comment workflows
now, the trigger behind the August attack.
The changelog doesn't say what happened to bindings created before October 2.
Bloom's advice to maintainers is to run npm trust list on every package,
check that each slug still points at a repository they control, and recreate
the binding after any rename or transfer.
If you install packages rather than publish them, that advice does nothing
for you. You can't see a package's trust binding from npm install. The 1,500 names are
unpublished. A malicious release from a reclaimed slug looks like any other
release, provenance included. Bloom's own line on the agent case: "It only
needs to be the package an agent chooses."
The same install, from a Bromure machine
In Bromure Agentic Coding, the agent runs in a Linux VM on your Mac, and
every request the VM makes, registry.npmjs.org included, crosses a proxy
on the host. The supply-chain policy runs in that proxy, outside the VM, so
nothing the agent or a package does inside the machine can change it. Follow
the SlugFest release into a Bromure machine.
A version that young isn't listed
The age gate is on by default in every new machine, with a two-day minimum.
When npm asks the registry for @acme/sdk, the proxy removes every version
published in the last two days from the listing and moves latest to the
newest version that's old enough.
The agent runs npm install @acme/sdk, the same command it would run
anywhere, and gets the maintainer's last real release. The attacker's
version isn't in the listing the agent sees. If the agent pins it by number
because a web page or an issue told it to, the tarball request gets HTTP
451, and npm prints the reason in the agent's terminal:
Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum
The gate works from the publish date alone, so you don't need Bloom's list or a malware signature. An attacker who reclaims a slug has to publish a new version, and a new version is a young one. While the agent installs the previous version, the maintainer, npm and the scanners have two days to notice a release nobody on the team made. A maintainer whose binding has never been used has good reason to look when it publishes for the first time.
More checks after two days
Turn on more layers in the machine's Supply Chain pane and the version meets them once it clears the gate. With socket.dev selected, every tarball is checked before it's served, and packages flagged as malware get a 451. With the OSV check on, a version with a published advisory is blocked at the severity you pick. With Strip preinstall / install / postinstall / prepare from npm tarballs on the fly, the proxy removes install hooks from the tarball before npm sees it, which takes away the payload delivery most npm malware relies on.
The payload finds stand-ins
Say a release still gets in and runs. It runs inside the VM, and the
credentials there are stand-ins. GH_TOKEN, ~/.git-credentials, the
Anthropic key, the AWS keys: each is a string with the real
thing's shape, derived from the real value and a per-install salt, and
useless anywhere except through the proxy on your Mac, for requests to the
host it belongs to. A payload that sends them to its own server sends
strings that fail authentication everywhere.
That request crosses the proxy too, which scans every outbound request for
stand-ins headed to the wrong host. A ghp_ stand-in on its way to a
collection server gets a 451, the VM pauses, and This environment may have
been compromised opens on the Mac with the credential's name, the host it
belongs to and the host it was sent to.
Your rename waits for a click
SlugFest starts with a rename, a transfer or a deleted repository, and
agents do that housekeeping too. In a Bromure machine, GitHub sits under
Prompt before write by default. Renaming a repository is a PATCH to
the GitHub API, and deleting one is a DELETE. Either one pauses on a
macOS dialog, Allow write on "github.com" from workspace "…"?, showing
the method and path as it appeared on the wire. When an agent tidies up a
project by renaming acme/sdk to acme/sdk-v2, you see it before the old
name becomes free, and you know which npm binding to recreate.
When the list comes out
Bloom will publish the affected packages at some point. Someone will then ask
which of your machines installed one of them, and when. The Security
Timeline records every package fetch from every machine, whatever the
policy: npm · @acme/[email protected], the machine, the time, and whether it was
downloaded, held back by the age gate or blocked. Filter on the package name
and export the result as CSV. The record sits on the Mac, where the agent
can't edit it.
The new version isn't listed
The age gate, on by default at two days, drops fresh versions from the
registry listing and moves latest back. The agent installs the
maintainer's last real release without knowing anything happened.
Pinned requests get a 451
If the agent asks for the new version by number, the download is refused and npm prints the package's age and the policy in the agent's terminal.
The payload finds stand-ins
Anything that runs in the VM finds stand-in tokens. Sent to the wrong host, one gets a 451, the VM pauses and the Mac shows an alert.
Renames and installs are on record
Prompt before write holds the repository rename that frees a slug. The Security Timeline records every package fetch, by machine and version.
The next name
GitHub's fix ties a binding to the repository's numeric identity after its first publish and expires bindings that never publish, which closes this gap. Trusted publishing protects the publishing credential and tells you which pipeline built the release. In August, the pipeline was the victim's own, triggered by a stranger's comment. This time, a stranger's repository answered to the right name. Both releases carried provenance.
Your agents install whatever the resolver returns, 70 packages a month by Bloom's count. Run them in a Bromure machine, where a two-day-old release doesn't exist yet and a payload that does run finds only stand-ins. For more on the age gate, read Anyone could publish that package and Revoking the token was the trigger.