The short version
- Shai-Hulud (September 2025) and Shai-Hulud 2.0 (November 2025) were self-replicating npm worms: install an infected package and it steals your tokens, then publishes infected versions of your packages.
- Luminair's desktop app locks 585 npm packages, including the command-line tools of Anthropic, OpenAI, Google and Moonshot. That is a big surface.
- Every release lane runs npm audit plus five of our own gates before it builds, then publishes a software bill of materials beside the app.
- Every package in the repository must be registered with its lockfile and audit command, or CI fails. Dependency bump branches never run on the Mac that also signs our releases.
It spread by publishing.
Most supply chain attacks are one-offs. Someone takes over a package, ships a bad version, and the damage is limited to whoever installs it before it is pulled. Shai-Hulud was different because it fed itself.
CISA's alert of 23 September 2025 lays it out. A self-replicating worm, publicly known as Shai-Hulud, “has compromised over 500 packages.” The malware “scanned the environment for sensitive credentials”, targeted GitHub tokens and cloud keys for AWS, Google Cloud and Azure, and then spread “by authenticating to the npm registry as the compromised developer, injecting code into other packages, and publishing compromised versions to the registry.”
In other words, the victim became the next attacker. Every developer machine it landed on that held an npm token was a way to reach more packages.
On 24 November 2025, Datadog Security Labs reports, “the community identified a second self-replicating npm worm”. Shai-Hulud 2.0 “has successfully taken over and backdoored 796 unique npm packages”, which together had “over 20 million weekly downloads.” Unit 42 noted what made it worse: “Execution during pre-install dramatically widened the area of impact.” The infected packages ran their payload before installation had even finished.
Two more details from Datadog's analysis matter to anyone running CI. The worm registered the infected machine as a self-hosted GitHub Actions runner, so the attacker could run commands on it later. And as a last resort it tried to destroy the user's home directory.
We install every vendor's CLI.
Luminair runs many AI engines. Several of them are shipped by their vendors as npm packages, and we bundle them so they work out of the box: @anthropic-ai/claude-agent-sdk, @openai/codex, @google/gemini-cli and @moonshot-ai/kimi-code are all direct dependencies of the desktop app.
Each of those pulls in its own tree. The desktop lockfile today lists 585 packages. Eight entries in it declare an install script, the hook that both Shai-Hulud waves used. Among them are native modules we genuinely need, such as the terminal library node-pty, and one of the vendor CLIs.
An agent app also runs on developers' own machines, next to their SSH keys, cloud credentials and npm tokens. That is exactly what the worms went looking for. So the question is not whether we depend on npm. It is what stands between a bad version appearing on the registry and that version reaching your Mac.
The lockfile is the first wall.
The quietest defence is also the most important. Every package in the repository that has dependencies must have a lockfile, and every CI job installs with npm ci, which installs exactly what the lockfile says and fails if it disagrees with package.json. Of the 585 entries in the desktop lockfile, 582 carry an integrity hash, so a tampered download of a pinned version does not install.
That means a newly published infected version does not reach our build just because it exists. It has to come in through a change to the lockfile, and those changes arrive in a small number of ways: someone upgrades on purpose, or Dependabot opens a pull request. CISA's first recommendation was the same idea: “Pin npm package dependency versions to known safe releases”.
Our GitHub Actions are pinned the same way. Every uses: line in our workflows names a full commit hash, not a tag that could be moved. And the build lanes check out the repository with persist-credentials: false, so the GitHub token is not left sitting in the checkout for an install script to find. The one job that pushes the version bump keeps it; it does not install dependencies.
Red means no build.
The next wall is a single npm script, security:audit in desktop/package.json. It runs six checks in a row, and any failure stops everything after it:
The first check is npm's own audit at --audit-level=high. If any installed package has a known high or critical advisory, the lane stops before it builds anything. This is not theoretical: on 7 September 2026 a commit updated qs and xmldom across three lockfiles, with the message “Update qs and xmldom patch ranges to clear dependency security gates.” Until those patches landed, nothing could ship.
The other five are ours. They check things a package manager cannot see: private keys or .env files in the source, that the app's package list has not regressed to a wildcard, the Electron security fuses, privacy rules and release policy.
After the gates, each lane runs CycloneDX to write a software bill of materials, a machine-readable list of every production package and version in that build, and uploads it next to the app, as Luminair-<version>-mac-<arch>-sbom.cdx.json and its Windows and Linux siblings. If a package is found to be infected next month, anyone can check whether a given Luminair build contained it without opening the app. Getting this to work on Windows took a fix on 3 September: npm ls flags node-pty as invalid on Windows, so the SBOM step now passes --ignore-npm-errors.
No package.json goes unnoticed.
Audits only protect the packages they run on. A repository grows new package.json files over time: a small tool, a video project, a helper for CI. Each one is a new dependency tree that nobody is auditing.
So the repository hygiene gate walks the whole tree, finds every package.json, and compares the list with a register, security/package-population.json. Every root must be declared, with its lockfile, the audit command to run on it, and a verification plan. A package that says it has no external dependencies must really have none. Anything found but not declared, or declared but not found, fails the gate.
| Package root | Lockfile | Dependency audit | Checks |
|---|---|---|---|
desktop | package-lock.json | npm audit --audit-level=high | 7 |
desktop/backend/functions | package-lock.json | npm audit --omit=dev --audit-level=moderate | 1 |
mobile | package-lock.json | npm audit --audit-level=high | 2 |
marketing-video | package-lock.json | npm audit --audit-level=high | 1 |
desktop/rooms | package-lock.json | npm audit --audit-level=high | 1 |
voice | none | no external dependencies | 1 |
desktop/runner | none | no external dependencies | 2 |
It has caught us. On 6 September 2026 a small internal package was added to the desktop folder without a register entry, and CI stopped. Our release runner keeps a list of known failures and how to handle them, and the entry written that day is blunt: “Reruns cannot fix this: the AI must register/exclude the package in package-population.json and push.” A new dependency tree cannot slip in by accident.
Where the install runs matters.
Shai-Hulud 2.0 turned infected machines into self-hosted GitHub runners. That is a reminder that a self-hosted runner is a real computer with real secrets.
Since 6 September 2026 our main CI gate runs on a self-hosted Mac. The same Mac can run the macOS release lanes, which import our Developer ID signing certificate into a temporary keychain and delete it at the end.
Then Dependabot pushed four bump branches at once, and each one queued a full install and test run on that Mac. The commit that followed is honest about the reason: it “made the desktop app lag badly.” The fix was one line in ci.yml: pushes to dependabot/** branches are ignored by the Mac gate. They are still checked, by the Security gates workflow on GitHub-hosted runners, which also runs the mobile, cloud functions and video audits and a Trivy scan for vulnerabilities and secrets.
The motive was speed. The effect is also a security one: an unreviewed dependency bump, the exact path a worm-infected version would take into our lockfile, never runs its install scripts on the machine that signs our releases. We would rather say that plainly than pretend we planned it that way.
npm install on your laptop can run install scripts next to your tokens. Luminair's per-session sandbox exists for that reason, and it is worth turning on in any session that installs packages.Checked, and not claimed.
What this post does not claim
- That these gates would stop a brand-new infected version. npm audit only knows about published advisories. A worm version that is hours old can pass it. The lockfile and a careful look at every bump are what slow it down.
- That we disable install scripts. We do not: several native modules we ship need them. That is exactly why where installs run matters.
- That Luminair was ever affected by either Shai-Hulud wave. We have no evidence it was, and we did not run a retrospective check for this post.
- That the Dependabot change was made for security. It was made because of lag, as the commit says.
For the packaging side of the same problem, read What ships in the bundle.
Sources
- CISA · 23 September 2025Widespread Supply Chain Compromise Impacting npm Ecosystem
- Palo Alto Networks Unit 42 · updated November 2025“Shai-Hulud” Worm Compromises npm Ecosystem in Supply Chain Attack
- Datadog Security Labs · updated 4 December 2025The Shai-Hulud 2.0 npm worm: analysis, and what you need to know
Gates before the build
Every Luminair release passes its dependency audit, package register and SBOM steps before it is signed.