Key Takeaway
Tensorlake's npm SDK version 0.5.144 carried a preinstall hook that ran a Shai-Hulud credential-stealing worm, released through the project's own workflow with valid provenance. Defend by blocking dependency install scripts by default, adding a release-age cooldown, and removing the gh-token-monitor persistence before revoking any GitHub token.
The strangest detail in this incident is the product. Tensorlake sells a cloud platform for running isolated AI agents and untrusted AI-written code, and its npm SDK was the thing that ran untrusted code on developer machines, outside any sandbox, the moment someone typed npm install.
This post is a close reading of three primary write-ups: SafeDep's payload analysis, The Register's report, and Tensorlake's own revert pull request, plus the npm and pnpm documentation for the controls I recommend. I did not detonate the payload or recover any binary, so where a claim is SafeDep's inference I say so. Nothing here contains a working exploit, only what defenders need.
What shipped in tensorlake 0.5.144
Version 0.5.144 of the tensorlake npm package was malicious, and 0.5.145 is the fix. The sources disagree on how popular the package is: The Register reports about 12,000 downloads per week, while SafeDep reports about 106,000 per month, which are different windows rather than a real conflict. Either way this was a mid-sized SDK, not a household name.
Detection was fast. SafeDep says npm records the publish at 01:12:07 UTC and that it flagged the version at 01:20, eight minutes later. The Register attributes an 11-minute figure to Socket's engine. I could not read Socket's own post (it returned a 403 to my fetch), so I cite the two numbers as reported and do not try to reconcile them.
The part that should worry maintainers is how it got there. SafeDep lists eight commits made through the GitHub web interface, all shown as verified, ending with the version bump. Tensorlake's revert PR says the malicious version was committed directly to main and released through the publish_npm.yaml workflow. The sources do not say how the administrator account was taken over, and I will not guess.
The release path was the weak point
SafeDep's finding is that the attacker did not need an npm token. A repository administrator account manually started the project's publish workflow, and the release used npm trusted publishing. The sibling tensorlake-native packages at 0.5.144 carry valid SLSA provenance for that run, so the provenance was genuine and the source it described was poisoned.
That is not a flaw in provenance. The npm documentation describes it as cryptographic proof of where and how a package was built, and describes trusted publishing as removing long-lived tokens through short-lived OIDC credentials specific to a workflow. Both did their job. Neither claims the source tree was benign, and that is the claim many teams quietly assume when they see a green provenance badge.
Tensorlake's revert PR shows what the maintainers changed in response: it reverts every commit after the last clean one, switches three workflows to npm ci --ignore-scripts, adds a tripwire that runs before dependency installs, and bumps the version to 0.5.145. Repository settings changed outside the PR include branch protection and reviewers on the npm environment. My reading is that a required second reviewer on a release environment is the control that would have turned one hijacked admin account into a blocked job, but that is my inference, not something the sources claim.
The preinstall hook and what the worm took
The entry point was an install-time lifecycle script, which npm runs automatically with the permissions of whoever ran the install. Socket's warning, as The Register relays it, is that this happens on a developer machine or build server, outside Tensorlake's sandbox, before any AI-generated code runs.
// The shape of the hook, as SafeDep describes it. package.json in 0.5.144:
// "scripts": { "preinstall": "node lib/setup.mjs" }
//
// What lib/setup.mjs did, in order (SafeDep's analysis, not my reproduction):
// 1. exit early if CI, GITHUB_ACTIONS, GITLAB_CI or RUNNER_ENVIRONMENT is set
// 2. download Bun 1.3.13 from the official GitHub release
// 3. run lib/Math_Symbol.js (856,501 bytes) with that Bun
//
// Step 1 is why "our CI install was clean" proves nothing. The hook was
// written to skip the build server and run on the laptop.
//
// Check your own tree for the three files SafeDep names:
// lib/setup.mjs 32,645 bytes
// lib/Math_Symbol.js sha256 b50a00900399ba99fb6ce1fc151519cb99d44320ef2a631f2237e1aea0ad6fec
// math_init.js (written by the worm into repos it touches)SafeDep's table of collection targets is long. I grouped it by what you would have to rotate. The second column is SafeDep's description, the third is my advice, not theirs.
| Target | What SafeDep says it reads | Defender response |
|---|---|---|
| Files on disk | SSH keys, cloud CLI config, .npmrc, .env, Docker and Kubernetes files, ~/.claude.json, ~/.kiro/settings/mcp.json, shell history | Rotate every credential that lived in those files, including MCP and AI tool tokens |
| Crypto wallets | wallet.dat, Exodus, Electrum, Ledger Live, browser extensions such as MetaMask and Phantom | Move funds to a freshly generated wallet from a clean machine |
| Browser passwords | A downloaded binary run with a password-dump argument; SafeDep infers it is HackBrowserData but did not recover it | Treat every saved browser login as exposed and change it |
| AWS | STS, SSM Parameter Store, Secrets Manager | Rotate keys, then review CloudTrail for reads of those services |
| GitHub Actions | Secrets read from Runner.Worker process memory | Rotate repository and organisation secrets, but only after the token monitor is gone |
| Kubernetes and Vault | Service-account tokens and Vault tokens | Revoke and reissue, then check audit logs for use from unknown addresses |
Command and control was built to survive takedowns. SafeDep reports a primary endpoint, a fallback that reads a contract on Ethereum through 35 public RPC endpoints, a fallback that trusts only GitHub commits signed with an embedded RSA key, and encrypted dead-drop repositories described as 'Shai-Hulud: Here We Go Again'. After exfiltration it polls every 45 to 90 seconds and can run code the server sends back. Blocking one domain does not end the incident.
The token monitor that punishes revocation
Both SafeDep and The Register describe the nastiest behaviour. The worm installs a watcher that checks stolen GitHub tokens, and if a token stops working it deletes the infected user's home directory. SafeDep gives the mechanics: the watcher arms only for tokens whose account has no organisations, checks every 60 seconds for 24 hours, and on a 40x response from GitHub runs a recursive delete of the home directory.
SafeDep names the persistence: a systemd user service called gh-token-monitor.service on Linux, a LaunchAgent labelled com.user.gh-token-monitor on macOS, and a scheduled task called gh-token-monitor on Windows. The Register passes on researchers' advice to disable the monitor before revoking credentials, and SafeDep says the same in plain words.
Order matters more than speed. Find and remove gh-token-monitor on every affected host before you revoke a single GitHub token. Revoking first can trigger the deletion. If you cannot find the monitor, work from a rebuilt machine, as Socket recommends in The Register's report.
# Read-only discovery first. Nothing here revokes a token.
# Linux: the systemd user service and its files (names from SafeDep)
systemctl --user list-unit-files | grep gh-token-monitor
ls -la ~/.local/bin/gh-token-monitor.sh ~/.config/gh-token-monitor/ 2>/dev/null
# macOS: the LaunchAgent label
launchctl list | grep com.user.gh-token-monitor
# Only after you have found it, stop it. THEN rotate GitHub tokens.
systemctl --user disable --now gh-token-monitor.service # Linux
launchctl remove com.user.gh-token-monitor # macOS
# Windows (PowerShell): scheduled task named gh-token-monitor
# Get-ScheduledTask -TaskName gh-token-monitor
# Is the package, or its files, anywhere in this checkout?
grep -n '"tensorlake"' package-lock.json | head
find . -path ./node_modules/.cache -prune -o \
\( -name Math_Symbol.js -o -name math_init.js \) -print
grep -rl "WORMTAG" --include=*.js --include=*.mjs . 2>/dev/null | headThe commands below are read-only until the stop lines. I wrote them from the artefact names SafeDep publishes, and I have not run them against a real infection, so confirm the names against SafeDep's indicator list before relying on a negative result.
The ChainDrop lineage and why scanners missed it
The Register says analysis links the Tensorlake release to ChainDrop, the Shai-Hulud variant first seen on 4 August 2026. That campaign infected 444 packages from multiple publishers, which together are downloaded about 2 billion times a month, including keyv, flat-cache and cache-manager. SafeDep adds a concrete link: the Tensorlake payload uses the same cipher salt as the keyv payload.
Two traits matter for defenders. First, ChainDrop spreads through rebuilt npm tarballs, so reading a repository's source can show nothing wrong. Second, it plants startup hooks in repository configuration, and The Register says merely opening an infected branch in VS Code or Claude Code can activate them. SafeDep reports the Tensorlake variant commits Claude Code and VS Code hooks as an author named claude and plants a workflow titled 'Run Copilot' that dumps repository secrets and then deletes the run and branch.
# Look for repository config you did not write, on EVERY branch.
# Checking only main is the mistake the ChainDrop advice warns about.
git fetch --all --prune
for ref in $(git for-each-ref --format='%(refname)' refs/remotes); do
git ls-tree -r --name-only "$ref" \
| grep -E '^(\.claude/settings\.json|\.vscode/tasks\.json)$' \
| sed "s|^|$ref |"
done
# SafeDep also lists a planted branch name and a planted workflow title.
git branch -r | grep 'dependabot/github_actions/format/setup-formatter'
git grep -n "Run Copilot" -- .github/workflowsActiveState's Abby Kearns, quoted in The Register's August story, advises checking every branch for .claude/settings.json and .vscode/tasks.json files you did not create, and treating repository-supplied configuration as executable content. She also calls it the first campaign to notice the gap in dependency scanning and use it at scale. SafeDep notes the worm has bugs that may limit its spread, such as the npm path stopping when a file it expects is missing. I would not count on bugs staying in place.
Locking down install scripts
The cheapest fix is the one Tensorlake itself applied to its own pipeline: do not let dependencies run code at install time unless you chose to. The table maps each control to its documented behaviour and to what it would have done to this particular hook. The last column is my reasoning, not a test result.
| Control | Setting | Documented behaviour | Effect on this incident (my reasoning) |
|---|---|---|---|
| npm: skip lifecycle scripts | ignore-scripts=true | If true, npm does not run scripts specified in package.json files. Default is false. | The preinstall hook never starts, so no loader, no Bun download, no payload |
| pnpm: build allowlist | allowBuilds, strictDepBuilds | Packages not listed are treated as unreviewed; strictDepBuilds, default true, exits non-zero when one has an unreviewed build script | A new version of an unlisted package fails the install instead of running its hook |
| Release-age cooldown | min-release-age, minimumReleaseAge | npm documents min-release-age in days, default null. pnpm documents minimumReleaseAge in minutes, default 1440 since v11 and 0 before | Only helps if the bad version is pulled before the cooldown ends. Detection took minutes here, but I did not find the removal time |
| CI install without scripts | npm ci --ignore-scripts | The flag Tensorlake's PR added to publish_npm.yaml, typescript_sdk.yaml and tests.yaml | Protects the build and release runners, which the loader skipped anyway; laptops still need the .npmrc setting |
| Trusted publishing plus a protected environment | id-token: write, optional environment name | npm docs: OIDC short-lived tokens; the environment field is optional and meant for deployment protection | Does not stop installs. It could have put a human approval between a hijacked admin account and a release |
# ---- .npmrc (npm) ----
# Do not run any package's lifecycle scripts during install.
ignore-scripts=true
# Only resolve versions published more than N days ago. The npm CLI v11
# config page documents this key in days; run "npm config get min-release-age"
# on your own npm to confirm it is recognised before you rely on it.
min-release-age=3
# ---- pnpm-workspace.yaml (pnpm) ----
# Minutes. The pnpm docs give 1440 (one day) as the default since v11, 0 before.
minimumReleaseAge: 1440
# Build scripts are an allowlist. Unlisted packages are "unreviewed".
allowBuilds:
esbuild: true
# Default true: exit non-zero when a dependency has an unreviewed build script.
strictDepBuilds: trueTensorlake's PR also adds a tripwire that fails CI if a published manifest declares install lifecycle scripts or if the SDK's lib folder contains unexpected files. I have not seen their script, only its description. The sketch below is my own take on the same idea, built on a package-lock.json v3 field, and it needs no network.
// scripts/check-no-install-scripts.mjs
// My own sketch of the tripwire idea from Tensorlake's revert PR (their script
// is described there, not reproduced here). Run it BEFORE any install.
// package-lock.json v3 marks every package that ships an install-time script
// with hasInstallScript: true, so this needs no network and no install.
import { readFileSync } from "node:fs";
// Packages you have reviewed and accept running build steps for.
const ALLOWED = new Set(["node_modules/esbuild"]);
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
const offenders = Object.entries(lock.packages ?? {})
.filter(([path, meta]) => path !== "" && meta.hasInstallScript)
.map(([path]) => path)
.filter((path) => !ALLOWED.has(path));
// Your own manifest should not declare install-time hooks either: that is
// exactly where the 0.5.144 payload was attached.
const own = JSON.parse(readFileSync("package.json", "utf8")).scripts ?? {};
const ownHooks = ["preinstall", "install", "postinstall"].filter((h) => h in own);
if (offenders.length || ownHooks.length) {
console.error("Unreviewed install scripts:", offenders.concat(ownHooks));
process.exit(1);
}
console.log("No unreviewed install scripts.");# .github/workflows/ci.yml (fragment)
permissions:
contents: read # no id-token: write on jobs that only build and test
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: node scripts/check-no-install-scripts.mjs # before the install
- run: npm ci --ignore-scripts # as in Tensorlake's PR
- run: npm testThe two halves complement each other. The .npmrc setting protects the laptop, where this worm wanted to run. The CI fragment and the tripwire protect the pipeline and give you a failing check the day someone adds a dependency with an install script.
Also treat AI tooling files as secrets. SafeDep lists ~/.claude.json and ~/.kiro/settings/mcp.json among the files it reads. If MCP server tokens live there, they belong in your rotation list and, better, in a secret manager with short lifetimes.
What breaks, and what this does not fix
Turning off install scripts breaks things. Packages that compile native code or download a binary at install time, esbuild being a common example, will fail or ship incomplete until you allowlist them. In pnpm that is a short list you review once; in plain npm, ignore-scripts=true is all or nothing, so expect to run a few build steps explicitly. That cost is real, and it is the reason many teams never switch it on.
A cooldown trades freshness for safety. A one-day or three-day delay also delays legitimate security patches, so keep an exclusion list for the packages where you want fixes immediately, and never use a cooldown as a substitute for reading advisories. The npm and pnpm docs both provide an exclusion setting for exactly this.
The deeper failure is the one the loader exploited: it exited on CI. A green pipeline said nothing about the developer laptops that hold the SSH keys, cloud credentials and AI tool tokens. And none of these settings help once a maintainer's admin account is taken, because the poisoned release was legitimately built. That needs branch protection, required reviewers on the release environment, and few people with admin rights.
What to do on Monday
Work through this in order. The order is deliberate, because step three is the one that can delete a home directory.
- Search every lockfile and machine for tensorlake at 0.5.144 and move to 0.5.145 or later; if you find 0.5.144 was ever installed, treat that machine as compromised and rebuild it rather than clean it.
- Look for the artefacts SafeDep lists: lib/setup.mjs, lib/Math_Symbol.js, math_init.js, the gh-token-monitor service, agent or task, and the planted dependabot branch.
- Stop gh-token-monitor on each affected host before revoking any GitHub token, then rotate GitHub, npm, cloud, SSH, Kubernetes and Vault credentials from a clean machine.
- Audit GitHub for .claude/settings.json and .vscode/tasks.json on every branch, for a workflow titled Run Copilot, and for unfamiliar public repositories under your account.
- Add ignore-scripts=true to .npmrc or the pnpm allowlist, and a release-age cooldown with a short exclusion list, and run npm ci --ignore-scripts in CI.
- Put branch protection on the default branch, require a reviewer on the npm release environment, and cut the number of repository administrators.
If you maintain a package yourself, also check which published packages your tokens can reach. SafeDep reports that the worm bumps the patch version of every package an npm token can publish, so the blast radius of one token is every package it can publish.
The rule I take from this: a dependency's install script is code you did not review running with your permissions, so deny it by default, allow it by exception, and never read a clean CI run or a valid provenance badge as proof that a package is safe.
Sources and further reading
- SafeDep: Mini Shai-Hulud in tensorlake 0.5.144, payload and indicators
- The Register: Shai-Hulud worm makes jump to AI infrastructure with Tensorlake compromise
- Tensorlake pull request 1016: revert the 0.5.144 compromise and harden the npm release path
- The Register: ChainDrop worm crawls into npm supply chain
- npm docs: config, ignore-scripts and min-release-age
- pnpm docs: minimumReleaseAge and minimumReleaseAgeExclude
- pnpm docs: allowBuilds and strictDepBuilds
- npm docs: trusted publishers, OIDC and provenance

