On October 8, tensorlake@0.5.144 was on npm for about 1 hour 40 minutes. It was not a real release. Someone with access to the maintainer's GitHub account committed a payload and ran the project's own release workflow, so the package even carried valid npm provenance. A preinstall hook ran the payload the moment it was installed on a developer machine.

The payload went after GitHub and npm tokens, AWS and GCP credentials, SSH keys, .env files and browser passwords, then used those tokens to republish itself into other packages. It also installed a small service called gh-token-monitor that deletes your home directory if the stolen GitHub token gets revoked (SafeDep found it only arms for tokens on accounts with no organizations). That's all in the maintainers' own GitHub advisory (GHSA-8g63-53c9-65j2), and it's part of the Mini Shai-Hulud campaign that has been hitting npm since April.

npm already has a fix for the "runs on install" part. npm 12, out since July 8, doesn't run dependency preinstall, install or postinstall scripts unless your project allows them.

Here's the catch. Node 26 became LTS on October 28, and the npm it ships is 11.20.0. That's not npm 12. I checked the bundled npm version for every current LTS line in the Node release index today:

Node release Date npm it ships
26.13.0 (LTS "Lithium") Oct 28, 2026 11.20.0
24.22.2 (LTS "Krypton") Oct 27, 2026 11.19.1
22.23.4 (LTS "Jod") Oct 20, 2026 10.9.9

The only Node line with npm 12 bundled right now is the 27 alpha. So if you install Node the normal way and never run npm install -g npm, you're on a version that still runs every install script in your tree.

What each package manager actually did

I wanted to see this rather than trust release notes, so on a scratch box I made a tiny local package whose preinstall writes a marker file to the temp dir. I installed it next to esbuild (a normal, harmless package that has a postinstall) with each tool, then checked whether the marker file existed.

Tool Script ran? What it printed
npm 10.9.9 (what Node 22 ships) yes nothing at all
npm 11.21.1 yes a warning listing both packages
npm 12.2.1 no "2 packages had install scripts blocked"
pnpm 10.34.6 no "Ignored build scripts: esbuild@0.25.11, harmless-preinstall@file:..."
bun 1.4.2 no "Blocked 1 postinstall"

Two things stood out. On npm 10 there's no hint anything ran. On npm 11 the warning reads like a heads-up about the future, but the marker file was there, so the script had already run. The script also had a console.log, and that line never showed up in the install output on npm 10 or 11. You don't see install scripts run.

pnpm and bun already block by default. If you're on either, most of this post is about your CI and your teammates, not you.

Check a project in 10 seconds

This prints your npm version, whether install scripts are on, which packages in your lockfile actually have install scripts (npm records hasInstallScript in package-lock.json), and whether any of the tensorlake persistence files exist on the machine.

#!/usr/bin/env bash # install-scripts-check.sh: who gets to run code when you type npm install? # usage: bash install-scripts-check.sh [project-dir] (needs jq) cd "${1:-.}" || exit 1 v=$(npm -v); major=${v%%.*}; ign=$(npm config get ignore-scripts) allow=$(jq -c '.allowScripts // empty' package.json 2>/dev/null) echo "node $(node -v), npm $v, ignore-scripts=$ign" if [ "$major" -ge 12 ]; then echo "OK npm 12+: dependency install scripts blocked unless allowed" elif [ "$ign" = "true" ]; then echo "OK ignore-scripts=true: install scripts off" else echo "WARN npm $major runs every dependency install script (it only warns)"; fi [ "$ign" = "true" ] && [ -n "$allow" ] && echo "WARN ignore-scripts=true silently overrides allowScripts" echo "allowScripts: ${allow:-none}" if [ -f package-lock.json ]; then n=$(jq '[.packages | keys[] | select(. != "")] | length' package-lock.json) echo "lockfile: $n packages; these have install scripts:" jq -r '.packages | to_entries[] | select(.value.hasInstallScript == true) | " \(.key | sub("^.*node_modules/"; ""))@\(.value.version)"' package-lock.json fi echo "tensorlake worm persistence (all should be missing):" for f in ~/.config/gh-token-monitor ~/.local/bin/gh-token-monitor.sh \ ~/Library/LaunchAgents/com.user.gh-token-monitor.plist; do if [ -e "$f" ]; then echo " FOUND $f <- remove BEFORE revoking GitHub tokens" else echo " missing $f"; fi done systemctl --user list-unit-files 2>/dev/null | grep -q gh-token-monitor \ && echo " FOUND gh-token-monitor systemd user service <- stop it first" for f in .claude/settings.json .vscode/tasks.json; do grep -qE 'SessionStart|folderOpen' "$f" 2>/dev/null && echo " CHECK $f runs a command on open. Did you write it?" done exit 0 

I ran it on a fresh lockfile for a pretty normal small-SaaS stack (next, react, react-dom, prisma 7, @prisma/client 7, stripe, tailwindcss, sharp) with the npm that was on the box:

node v24.22.1, npm 11.21.1, ignore-scripts=false WARN npm 11 runs every dependency install script (it only warns) allowScripts: {"prisma@7.10.1":true,"@prisma/engines@7.10.1":true} lockfile: 191 packages; these have install scripts: @prisma/engines@7.10.1 prisma@7.10.1 tensorlake worm persistence (all should be missing): missing /home/box/.config/gh-token-monitor missing /home/box/.local/bin/gh-token-monitor.sh missing /home/box/Library/LaunchAgents/com.user.gh-token-monitor.plist 

That allowScripts list is already there because I approved Prisma with npm 12 first (next section). The script still says WARN on npm 11, and that's fair. On 11.21.1 a package you explicitly deny does get skipped (I tested that), but anything new that isn't on the list still runs. Only npm 12 flips the default. With npm 12.2.1 first on the PATH, the same project gives OK npm 12+: dependency install scripts blocked unless allowed.

The number worth noticing: 191 packages in the lockfile, 2 with install scripts. Your allowlist will probably be short. Short enough to read every entry.

Moving to npm 12 without breaking your build

npm install -g npm@12 npm install # blocked scripts get listed at the end npm install-scripts ls # same list, read-only npm install-scripts approve prisma @prisma/engines npm install-scripts deny some-package-you-dont-trust npm rebuild # runs the scripts you just approved git add package.json && git commit -m "allowScripts for npm 12" 

On the starter project above, npm 12 listed @prisma/engines@7.10.1 (postinstall: node scripts/postinstall.js) and prisma@7.10.1 (preinstall: node scripts/preinstall-entry.js). After the approve step, package.json had:

"allowScripts": { "prisma@7.10.1": true, "@prisma/engines@7.10.1": true } 

Approvals are pinned to the version you reviewed by default. When Prisma bumps, the new version is blocked again until you look at it. That's a little annoying and it's exactly the point. A hijacked patch release of a package you trust is how these worms spread. npm install-scripts prune cleans out entries for versions you no longer have.

GitHub's npm v12 breaking-changes note covers two other defaults that changed at the same time: git dependencies and remote tarball URLs are no longer resolved unless you allow them. If your install breaks on a github: dependency after upgrading, that's why.

Three gotchas

ignore-scripts=true beats your allowlist. Setting it in .npmrc was a common hardening tip after the first Shai-Hulud wave in September 2025. On npm 12 it silently wins over allowScripts. I approved my test package, ran npm install --ignore-scripts, and the marker file never appeared. Same install without the flag, it did. Pick one setup and remove the other.

CI has its own npm. Upgrading on your laptop does nothing for the build server. Cloudflare's Oct 23 changelog says their Workers Builds and Pages images now ship Node 22.23.4 and 24.22.1, and per the Node release index those come with npm 10.9.9 and 11.19.1. If your CI sets up Node with something like actions/setup-node, you get that Node's bundled npm unless you upgrade it. Add an npm install -g npm@12 step before npm ci, or check what your builder uses.

Your laptop is the real target. SafeDep's teardown of the tensorlake payload shows the loader checks CI, GITHUB_ACTIONS, GITLAB_CI and RUNNER_ENVIRONMENT and simply exits on CI. It wants the machine where your SSH keys, .env files and logged-in browser live. That's also where AI coding agents type npm install for you without you reading the package name. I wrote down some rules for keeping coding agents from installing junk or reading your secrets on a side project, and they matter more once you know worms skip CI on purpose.

If you think you installed something bad

The order matters here, and it's the opposite of what your instincts say.

  1. Take the machine off the network.
  2. Remove gh-token-monitor before you revoke anything. The advisory gives the commands: on Linux systemctl --user disable --now gh-token-monitor.service and delete ~/.config/gh-token-monitor/ and ~/.local/bin/gh-token-monitor.sh; on macOS launchctl bootout the com.user.gh-token-monitor LaunchAgent and delete the plist.
  3. Rotate tokens from a different, clean machine.
  4. Look through the repos and packages your tokens could write to. The tensorlake advisory says to look for unexpected releases, commits authored claude, and a branch named dependabot/github_actions/format/setup-formatter. Sonatype and Socket also flag changed .claude/settings.json and .vscode/tasks.json files, which is why the script greps them.
  5. Rebuild the machine from a clean image.

What npm 12 doesn't fix

Blocking install scripts closes one door. Code that runs when you import the package is untouched. A GitHub advisory from October 9 for an npm package called testrrrd describes exactly that: it fires the same beacon from postinstall and again from its main file at import time, so --ignore-scripts alone doesn't stop it. If a bad version lands in your lockfile, you'll run it the first time your app starts.

So the lockfile still matters. Commit it, use npm ci in CI, and don't auto-merge dependency bumps the same hour they're published. It's the same lesson as the trivy-action tags that got repointed in one night: a name you trust is not the same as the code you get.

For most small projects the whole fix is upgrading npm, one install, approving two or three packages and committing package.json.

If you've already moved to npm 12: how many packages ended up in your allowScripts, and which one broke first when you upgraded?