On May 11, an attacker published 84 malicious versions across 42 @tanstack/* packages. The compromise was caught within 20 minutes, but only because the payload broke tests. A quieter payload would have stayed in the registry for hours, with credentials harvested from every CI run that installed the package in the meantime.
The most interesting part of the postmortem is how the attacker got code into a release workflow without ever committing to main. The compromise required three vulnerabilities chained together, and the load-bearing one in the middle was GitHub Actions cache poisoning.
This post is about why that middle link doesn't exist on RWX.
#How the cache leg of the attack worked
The TanStack workflow ran fork pull requests via pull_request_target, which exposed default branch permissions to untrusted code. That's the well-known "Pwn Request" pattern. But on its own, pull_request_target only gives the attacker a hostile runner — not a way to reach other workflows running on main.
GitHub's cache is what allowed the attacker to bridge to main. Runs on PRs ordinarily cannot write to a cache that is used by main, but runs from the pull_request_target are an exception: they run in the context of main and thus can write to the cache that other runs on main will restore. The attacker's PR ran a build step that wrote a poisoned pnpm store into the GitHub Actions cache. Hours later, the release workflow on main restored the poisoned cache, installed the attacker's binaries, and the rest of the chain ran from inside a privileged context.
The whole exploit pivots on one assumption: that a cache key written by one workflow is interchangeable with a cache key written by another, as long as the string matches.
#Cache keys on RWX aren't strings
On RWX, you don't write cache keys. There's no key: field, no hashFiles(), and no manual orchestration. The cache key for a task is derived from the actual inputs to that task: the command being run, the environment, the base layer, the upstream tasks it depends on, and the exact contents of every file inside its filter. Tasks are sandboxed to only see the files in their filter, so the inputs the cache key is computed from are the inputs the task actually receives.
A run from a fork PR and a run from main only share a cache entry if every one of those inputs matches byte-for-byte. If the fork modified a file — say, dropping in a router_init.js postinstall script — the task's content hash is different from main's, and the runs land in different cache slots. There is no shared key for the attacker to overwrite, because there are no keys to begin with.
We wrote about this last year in Sandboxing is the Key to Caching without False Positives. The framing there was correctness — preventing false-positive cache hits that ship bugs to production. The same property is what makes the cache tamper-resistant.
#Incremental caches live in branch-locked vaults
Content-based caching covers the cold-cache case. The other thing CI platforms cache is incremental state. RWX calls these tool caches, and they're stored in vaults.
Vaults can be locked to specific branches and even to specific people. The recommended pattern is to put your tool cache in a vault locked to main:
tool-cache:vault: your_repo_maintasks:- key: npm-installrun: npm installtool-cache: ci-npm-install
When a run on main executes, the vault is unlocked, so the tool cache can be read and written. When a run on a feature branch (or a fork PR) executes, the vault is locked: the run can read from the cache to speed itself up, but it cannot write back. There's no escape hatch like pull_request_target to allow code from a fork to populate the incremental cache that protected branches consume.
This is a structural defense rather than a procedural one. RWX does support requiring manual approval before a fork PR runs at all, and that's a reasonable additional layer. But we don't want the security model to only depend on a reviewer catching that. With vaults locked to branches, the fork PR can't populate main's cache even if a reviewer waves it through.
#Pinned packages by default
There's one other security layer worth mentioning. RWX packages are always pinned to a specific version, and updates are validated for backwards compatibility. You write nodejs/install 1.1.14, not nodejs/install or nodejs/install ^1.1. If a package itself calls other packages, those package versions are also pinned, so there's no transitive risk either. We've made the case for this design before in Designing a Software Supply Chain for Security and Reliability and GitHub Actions is Vulnerable to Supply Chain Attacks. The TanStack attack isn't a pinning failure on the publishing side, but the risks of not pinning are well known, and our approach simply removes yet another security risk that workflow implementors need to consider.
#Conclusion
Cache poisoning attacks against CI exist because most CI platforms treat the cache as a key-value store with weak isolation between trust boundaries. Anything that can write a key can poison it, and anything that can read a matching key trusts the value.
RWX doesn't have keys. It has content hashes derived solely from inputs, and it has vaults that scope writes to specific branches. The TanStack compromise needed both a shared key namespace and a way to run forked code with write permissions. Neither exists on RWX.
We're not claiming supply chain attacks are solved. The upstream npm ecosystem is still the upstream npm ecosystem, and any package you depend on is a package that could be compromised. But the CI-side amplifier that allowed these packages to be compromised is a class of bug we designed RWX not to have.
If you're rebuilding a pipeline after this week, getting started with RWX is easy, and you can always grab time with our team.
Related posts

Unlock agent-native CI/CD with the RWX Skill
RWX has launched an official skill for working with the RWX CLI and run definition files.

Trigger runs via Webhook
You can now trigger RWX runs from webhooks from third-party services. This is helpful for using RWX to run custom automation.

