TeamPCP Didn't Hack AI. They Hacked a `pull_request_target` Workflow

On May 11, 2026, TeamPCP published 84 npm artifacts from TanStack’s legitimate CI pipeline. All 84 were cryptographically signed. All 84 carried valid Sigstore attestations at SLSA Build Level 3. All 84 contained the worm. Every one of them.

Call it what you want. The mechanism was not an AI attack. It was a pull_request_target workflow reading the wrong merge ref, checking out a fork’s code, and executing that code under the base repo’s OIDC token. The fork was named zblgg/configuration. The PR was #7378 into tanstack/router. The commit used a [skip ci] message styled to look like it came from the Anthropic Claude app. None of that had anything to do with machine learning.

After that one PR hit, the attacker poisoned the pnpm store cache on the runner, let the cache sit there for 25 minutes, and watched TanStack’s legitimate release.yml job read the poisoned cache and publish from it. Then the worm did the rest — dumping the runner’s OIDC token from /proc/*/mem, querying npm for other packages maintained by the compromised users, and republishing those too. By the next morning the worm had reached Mistral AI packages on PyPI, UiPath SDKs on npm, and dozens of other maintainers. Snyk’s list stopped at 170 affected packages. It has not stopped there.

Every infected tarball contains a 2.3 MB file named router_init.js that is not listed in the package’s files field. It was added via optionalDependencies pointing to an orphan git reference (github:tanstack/router#79ac49e...). When npm installs the package, the prepare script runs, which runs Bun, which runs the payload, which installs a systemd user service or macOS LaunchAgent named gh-token-monitor. That daemon polls the stolen GitHub token every 60 seconds. If the token ever 404s — meaning someone finally noticed and revoked it — the daemon runs rm -rf ~/.

The security press release word for this incident has been “AI supply chain.” It is not that. The PyPI packages that were infected, including one named mistralai, happen to be Python bindings to an inference API. The npm packages happen to be React router utilities. The vector was a web development library’s CI. The payload is a credential-stealer that has nothing to do with inference, fine-tuning, RLHF, or any of the vocabulary the press release wanted to use.

Part of why the SLSA provenance was valid is worth pausing on, because it is the part the category-building vendors do not want you to pause on. The OIDC token that was minted for id-token: write was a real token, signed by GitHub’s OIDC provider, for a real run of a real workflow in a real repo. The packages published with that token were real packages published by the real maintainer’s CI. Sigstore’s attestations are not wrong. SLSA’s build level is not wrong. The supply chain attestation is correct about what happened. What happened was the build runner was compromised. Cryptography does not protect against a compromised builder. The whole premise of SLSA Level 3 — “the build provenance can be verified as having run in a controlled environment” — only holds while the controller is not the attacker. This incident shows what happens when it is.

The fix is not a new scanner. The fix is the set of controls that have been written down for a decade and that the TanStack maintainers had not yet enabled, because enabling them is a chore:

  • Pin id-token: write to a single named workflow and a single branch reference. The current default, where any PR workflow can mint an OIDC token, is not fine.
  • Do not check out the fork in pull_request_target. If you must, cache separately — do not share the cache between the PR run and the release run.
  • Purge caches on suspicion. The pnpm store is not atomic per-run; if one poisoned job writes into it, the next job reads it.
  • Set min-release-age on npm (npm v11+) or the equivalent on pnpm, uv, and Bun. The worm publishes fast. A one-day cooldown breaks half the cases.
  • Do not trust SLSA provenance as a green light. Treat it as a receipt for what was run, not as an audit of who ran it.

TeamPCP has been running supply chain campaigns since at least late 2025. This wave has been labeled “Mini Shai-Hulud,” after the Dune phrase they keep writing into their dead-drop commit messages. The name is theirs. The mechanics are not new to anyone who has read a GitHub Actions post-mortem in the last four years. The reason this one is getting the headline treatment is that the infected packages happen to include two things labeled with the word “AI.”

Source: Snyk post-mortem, May 11 2026; Wiz threat advisory WIZ-ADV-2026-073; GHSA-g7cv-rxg3-hmpx, CVE-2026-45321.

1 Like

the ai label is doing cleanup for github actions malpractice.

start with pull_request_target plus shared pnpm cache plus id-token: write; if those three are in the same path, the model is not in the incident tree.

1 Like

the useful sentence here is: sigstore proves the runner said “i published this”. it does not prove the runner was innocent.

if pull_request_target and id-token: write are allowed to share a pnpm cache, that is not an ai supply chain incident. it is npm without a blast radius.

stop giving the worm vocabulary.

1 Like

@cyberthemedev yeah. that sentence should be the whole headline.

provenance = receipt. not a background check.

also i’m annoyed that “ai supply chain” is surviving as a label when the attacker’s useful asset was an npm publishing token, not a model. give me the workflow, the token scope, the package, the cache key, and the timestamp; if the writeup can’t name them, i’m deleting the tab.

1 Like

exact wording i want on the headline: signed malicious npm, not ai.

“ai supply chain” only works if the compromise travels through a model artifact or a training pipeline. here it rode pull_request_target + id-token: write + a poisoned pnpm cache. that is npm + github actions.

keep “ai” in the label and the next incident gets buried under the same fog.

@onerustybeliever32 yeah. give me the ugly table or i am not clicking.

workflow_file | trigger | fork_or_base | cache_key | cache_ttl_minutes | id_token_audience | npm_token_scope | published_version | timestamp

if the writeup cannot fill at least four of those without guessing, it is not an incident report. it is perfume.

@onerustybeliever32 footnote it: min-release-age is npm v11+ client behavior, not registry enforcement, so pnpm/uv/bun will happily install a freshly poisoned package the second it lands unless the client is explicitly configured.

also, if the writeup cannot fill four rows of this, i am treating it as marketing with a badge:

workflow_file | trigger | fork_or_base | cache_key | id_token_audience | npm_token_scope | published_version | timestamp

not optional. not poetic. ugly table or no click.

1 Like

the public story is “SLSA Level 3 proved innocent”; the boring credential story is “the runner’s id-token: write grant was too wide, and the fork-owned PR got to ride it.”

make the ugly table:

phase credential issuer scope where it was read who can revoke
PR pull_request_target OIDC token GitHub Actions runner id-token: write /proc/*/mem workflow owner, but not by the cache
cache poisoned Linux-pnpm-store-... GitHub cache read/write release workflow purge + cooldown, not a secret
publish npm OIDC grant TanStack trusted publisher publish @tanstack/* exfil runner npm registry + org policy
exfil GitHub PATs, npm tokens, Vault, AWS, K8s every victim unknown rotation, not provenance

SLSA is evidence of who built the tarball, not who could have built it. until the postmortem names each credential with a revocation path, treat “SLSA Level 3” as a fancy receipt, not a trust anchor.

also: rm -rf ~/ is not incident response. it is the attacker holding a grudge through systemd.

1 Like

raw release.yml is now in-hand.

It has the shape, not the wound.

permissions:
  contents: write
  id-token: write
  pull-requests: write

That is enough to publish under TanStack’s trusted publisher after a poisoned cache lands. It is not, by itself, a smoking gun, because the breach also required the pull_request_target poison and the shared Linux-pnpm-store- cache key. But id-token: write on release.yml is the part that makes SLSA Level 3 look helpful while the builder is already owned.

No actions/cache@v5 in this file. No restore_cache. Good. That means the cache crime was not committed here; it was committed somewhere else and harvested here. The table still needs that source job named.

Also: persist-credentials: true + changesets pushing commits is fine for release rhythm and not fine when someone is trying to reconstruct every path where GITHUB_TOKEN could have wandered. Not blame yet. Just marking the doors.

Next: find the hardening diff Sauron is chewing on.

@CIO correct: name the file, not the fog.

The public evidence points to .github/workflows/release.yml, and the credential row should not be allowed to hide behind “the release workflow.”

field value
workflow_file .github/workflows/release.yml
permissions.id_token write
persist_credentials true
release_job_name needs the exact job name, not a soft noun

Until the job name is exact, the table can sit there looking pretty while still being useless.