Guide
Planted .claude/settings.json in your repo? Scan for the tensorlake npm worm
By Aman Singh · Published · 9 min read
On 8 October 2026 a malicious tensorlake release, version 0.5.144, shipped an npm worm — tracked as "Mini Shai-Hulud" and written up elsewhere as "Shai-Hulud: Here We Go Again". Unlike a plain malicious dependency, it is self-propagating: a preinstall hook runs on install, steals your npm and GitHub tokens and other credentials, uses them to publish more infected packages, and commits files into your own repositories. The last clean version is 0.5.143; a fixed 0.5.145 followed.
Among the files it drops are .claude/setup.mjs, .vscode/setup.mjs and the editor-hook files .claude/settings.json and .vscode/tasks.json. It pushes them on a branch named dependabot/github_actions/format/setup-formatter, with commits authored by claude and messages like chore: update dependencies, so they look routine and slip through review. This guide helps you find them with a free, detect-only scanner, and clean up in an order that will not get your home directory wiped.
Think you were hit? Get help cleaning up: Security patching →
What the worm does
The behaviour is documented in the project's own GitHub advisory and several vendor write-ups. When the compromised package is installed, itspreinstall script runs and:
- Harvests secrets. It scans the machine for npm and GitHub tokens, SSH and GPG keys, cloud credentials, and Kubernetes and Vault tokens, and reads AI-tool and MCP configurations.
- Spreads. With a stolen npm token it publishes trojanized versions of packages the victim can write to, which is how
tensorlakebecame a carrier. - Exfiltrates publicly. It pushes the stolen credentials to public GitHub repositories; researchers found several left behind on compromised accounts.
- Plants editor hooks. It commits
.claude/setup.mjs,.vscode/setup.mjs,.claude/settings.jsonand.vscode/tasks.jsoninto repos so the payload runs again when a developer opens the project. - Persists. It drops
gh-token-monitoras a systemd user service (Linux), a LaunchAgent (macOS) or a scheduled task (Windows) to survive reboots — and to punish token revocation.
Am I affected?
Treat it as worth checking if any of these are true:
- You or your CI installed
tensorlakeor atensorlake-native-*package at0.5.144(it was live on 8 October 2026, roughly 01:12–02:54 UTC). Check lockfiles and CI logs for that version. - A repo has a branch
dependabot/github_actions/format/setup-formatter, or a commit authored byclaudethat nobody on the team made. - New
.claude/or.vscode/files (setup.mjs,settings.json,tasks.json) appeared that you did not add. - There is a
~/.config/gh-token-monitorfolder, agh-token-monitorsystemd service, LaunchAgent or scheduled task.
Scan your repos and machines
This is a single Node file with no dependencies. It only reads files and prints what it finds — it never deletes, revokes or changes anything, so it is safe to run on a machine you already suspect. It checks dependency manifests and installed packages for the known-bad versions, flags the planted files, reads recent git history and branches for the worm's artifacts, and looks for the gh-token-monitor persistence in your home directory.
# Node 18+; no dependencies, reads files only, changes nothing.
node tensorlake-scan.mjs # scan the current folder + your home dir
node tensorlake-scan.mjs ~/code # or a folder that holds many repos
# exit 1 = indicators found, 0 = none in what was scanned#!/usr/bin/env node
// tensorlake-scan.mjs: DETECT-ONLY scanner for the "Mini Shai-Hulud" npm worm
// (also written up as "Shai-Hulud: Here We Go Again") that shipped in the
// malicious tensorlake 0.5.144 release on 8 October 2026. It never deletes,
// revokes or changes anything — it only reports indicators of compromise so a
// human can decide what to do.
//
// node tensorlake-scan.mjs # scan the current directory + $HOME
// node tensorlake-scan.mjs /path/to/repos # scan a folder of repos + $HOME
//
// What it looks for:
// 1. The malicious package versions in package.json / lockfiles / node_modules
// (tensorlake and tensorlake-native-* at 0.5.144; last clean is 0.5.143).
// 2. Files the worm drops/commits (.claude/setup.mjs, .vscode/setup.mjs,
// setup.mjs, and the lower-signal .claude/settings.json / .vscode/tasks.json).
// 3. Worm git artifacts: a branch named
// dependabot/github_actions/format/setup-formatter, commits authored by
// "claude", and messages "chore: update dependencies" / "Add Copilot workflow".
// 4. The gh-token-monitor persistence dropped in your home directory.
//
// READ THIS BEFORE YOU CLEAN UP — THE HOSTAGE-TOKEN TRAP:
// The worm installs a watcher (gh-token-monitor) that checks the stolen
// GitHub token every 60 seconds for 24 hours and DELETES your home directory
// if the token is revoked while the watcher is still running. So the order is:
// (1) disconnect the machine, (2) delete the watcher and its service/task,
// THEN (3) rotate the GitHub and npm tokens. This script only reports what is
// there; it does not touch any of it.
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const BAD_VERSION = "0.5.144"; // tensorlake and its native sidecar packages
const isBadPackage = (name, version) =>
(name === "tensorlake" || name.startsWith("tensorlake-native")) &&
String(version).replace(/^[\^~>=<\s]+/, "") === BAD_VERSION;
const WORM_BRANCH = "dependabot/github_actions/format/setup-formatter";
const WORM_COMMIT = /chore:\s*update dependencies|Add Copilot workflow/i;
// Higher-signal planted files first; the last two are also legitimate elsewhere.
const PLANTED_ALERT = [
path.join(".claude", "setup.mjs"),
path.join(".vscode", "setup.mjs"),
"setup.mjs",
];
const PLANTED_REVIEW = [
path.join(".claude", "settings.json"),
path.join(".vscode", "tasks.json"),
];
const findings = []; // { level: "alert" | "review", msg }
const alert = (msg) => findings.push({ level: "alert", msg });
const review = (msg) => findings.push({ level: "review", msg });
/** Reads JSON without throwing; returns null on any problem. */
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
}
/** Flags any known-bad package@version in a dependency map. */
function checkDeps(deps, where) {
if (!deps) return;
for (const [name, range] of Object.entries(deps)) {
if (isBadPackage(name, range)) alert(`${where}: depends on ${name}@${range} (known compromised)`);
}
}
/** Checks an installed node_modules/<pkg>/package.json for a bad version. */
function checkInstalled(pkgJsonFile) {
const pkg = readJson(pkgJsonFile);
if (pkg?.name && isBadPackage(pkg.name, pkg.version)) {
alert(`${pkgJsonFile}: ${pkg.name}@${pkg.version} is installed (known compromised)`);
}
}
/** Looks at a single repo: planted files, and worm git artifacts. */
function checkRepo(dir) {
for (const rel of PLANTED_ALERT) {
if (fs.existsSync(path.join(dir, rel))) alert(`${path.join(dir, rel)}: present — a file this worm drops`);
}
for (const rel of PLANTED_REVIEW) {
if (fs.existsSync(path.join(dir, rel))) review(`${path.join(dir, rel)}: present — legitimate for many projects, but the worm plants this exact file; check git blame`);
}
if (!fs.existsSync(path.join(dir, ".git"))) return;
const git = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
try {
if (git(["branch", "--all"]).includes(WORM_BRANCH)) alert(`${dir}: branch "${WORM_BRANCH}" exists (worm artifact)`);
} catch {
/* git unavailable */
}
try {
const log = git(["log", "--all", "--format=%H%x09%an%x09%ae%x09%s", "-n", "500"]);
for (const line of log.split("\n").filter(Boolean)) {
const [hash, name, email, subject] = line.split("\t");
const short = hash?.slice(0, 10);
if (WORM_COMMIT.test(subject ?? "")) alert(`${dir}: commit ${short} — "${subject}" (worm commit message)`);
else if ((name ?? "").toLowerCase() === "claude" || /@users\.noreply\.github\.com$/.test(email ?? "")) {
review(`${dir}: commit ${short} authored by ${name} <${email}> — confirm you made it`);
}
}
} catch {
/* not a git repo, or git unavailable */
}
}
/** Walks a tree (bounded depth) collecting repos and dependency manifests. */
function walk(root, depth = 0) {
if (depth > 6) return;
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return;
}
const names = new Set(entries.map((e) => e.name));
if (names.has("package.json")) {
const pkg = readJson(path.join(root, "package.json"));
if (pkg) {
checkDeps(pkg.dependencies, path.join(root, "package.json"));
checkDeps(pkg.devDependencies, path.join(root, "package.json"));
checkDeps(pkg.optionalDependencies, path.join(root, "package.json"));
}
checkRepo(root);
}
for (const e of entries) {
if (!e.isDirectory()) continue;
if (e.name === "node_modules") {
for (const dep of fs.readdirSync(path.join(root, "node_modules"), { withFileTypes: true }).filter((d) => d.isDirectory())) {
if (dep.name === "tensorlake" || dep.name.startsWith("tensorlake-native")) {
const f = path.join(root, "node_modules", dep.name, "package.json");
if (fs.existsSync(f)) checkInstalled(f);
}
}
continue;
}
if (e.name === ".git") continue;
walk(path.join(root, e.name), depth + 1);
}
}
/** Checks the home directory for the gh-token-monitor persistence. */
function checkHome() {
const home = os.homedir();
const spots = [
path.join(home, ".config", "gh-token-monitor"),
path.join(home, ".local", "bin", "gh-token-monitor.sh"),
path.join(home, ".config", "systemd", "user", "gh-token-monitor.service"),
path.join(home, "Library", "LaunchAgents", "com.user.gh-token-monitor.plist"),
];
if (process.env.LOCALAPPDATA) spots.push(path.join(process.env.LOCALAPPDATA, "gh-token-monitor"));
for (const s of spots) {
if (fs.existsSync(s)) alert(`${s}: present — the worm's persistence. Remove it (and its service/task) BEFORE revoking any token (see the hostage-token note).`);
}
}
function main(argv) {
const root = path.resolve(argv[0] ?? ".");
console.log(`tensorlake-scan — detect only, nothing is changed\nscanning: ${root}\n`);
walk(root);
checkHome();
const alerts = findings.filter((f) => f.level === "alert");
const reviews = findings.filter((f) => f.level === "review");
if (alerts.length) {
console.log("INDICATORS FOUND — investigate as a possible compromise:");
for (const f of alerts) console.log(` [!] ${f.msg}`);
}
if (reviews.length) {
console.log("\nWorth a look (not proof on their own):");
for (const f of reviews) console.log(` [?] ${f.msg}`);
}
if (!alerts.length && !reviews.length) {
console.log("No indicators of the tensorlake / Shai-Hulud worm found in what was scanned.");
console.log("This is not a guarantee — scan every machine and CI runner that installed packages.");
}
if (alerts.length) {
console.log(
"\nIf you found indicators: disconnect the machine, remove gh-token-monitor\n" +
"and its systemd service / scheduled task FIRST, then rotate GitHub + npm\n" +
"tokens. Do not revoke tokens while the watcher is still installed.",
);
}
return alerts.length ? 1 : 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = main(process.argv.slice(2));
}The hostage-token trap
The instinct after a token theft is to revoke the token immediately. With this worm that can be the worst move. The advisory describes the gh-token-monitor watcher as a dead-man's switch: it polls the stolen GitHub token every 60 seconds for 24 hours, and if GitHub starts rejecting it while the watcher is still installed, it wipes the user's home directory. So the watcher has to go before the token does.
Clean up in the right order
- 1Isolate the machine. Take it off the network so it cannot publish packages or reach its exfiltration repos while you work.
- 2Find and remove the persistence first. Delete
~/.config/gh-token-monitorand~/.local/bin/gh-token-monitor.sh, and remove the service or task: the Linux systemd user service~/.config/systemd/user/gh-token-monitor.service, the macOS LaunchAgent~/Library/LaunchAgents/com.user.gh-token-monitor.plist, or the Windows scheduled taskgh-token-monitorand%LOCALAPPDATA%\gh-token-monitor. Only after the watcher is gone is it safe to touch tokens.Terminal # Inspect first, then remove — check what is there before deleting. ls -la ~/.config/gh-token-monitor ~/.local/bin/gh-token-monitor.sh 2>/dev/null cat ~/.config/systemd/user/gh-token-monitor.service 2>/dev/null # Linux ls -la ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null # macOS # Windows (PowerShell): Get-ScheduledTask -TaskName gh-token-monitor - 3Now rotate every secret. GitHub personal access tokens and OAuth apps, npm tokens, SSH and GPG keys, and every cloud or provider key those machines could read. Assume anything in their environment is gone.
- 4Clean the repos. Revert the worm's commits, delete its branch and the planted
.claude/.vscodefiles, and check whether any infected package versions were published under your name on npm (deprecate and unpublish those). - 5Rebuild, do not disinfect. Reinstall the machine or container from a known-clean image rather than trusting a cleanup.
Stop it happening again
- Turn off install scripts in CI. Run
npm ci --ignore-scriptswhere you can; apreinstall-based payload dies there. - Pin and review dependencies. Commit lockfiles, require review on
package.jsonchanges, and add a cooldown before adopting brand-new releases. - Scope tokens tightly. Short-lived, least-privilege npm and GitHub tokens, and publish from CI with provenance rather than a long-lived token on a laptop.
- Protect your main branch so a stolen token cannot push a
chore: update dependenciescommit straight to it.
Common questions
I have .claude/settings.json in my repo — am I infected?+
Why can't I just revoke the stolen GitHub token right away?+
Is it only the tensorlake package?+
The scanner found nothing. Am I safe?+
Think you were hit? Get help now
I can help you scan your machines and repositories, remove the persistence safely, rotate what was exposed, and rebuild clean. See security patching. Related: the LiteLLM 1.107.0 compromise and rotating leaked environment variables.
Sources
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call