Claude Code and VS Code Hooks: How the keyv npm Worm Persists After You Delete It
The keyv npm worm writes hooks into .claude/settings.json and .vscode/tasks.json. Deleting node_modules does not remove them.

- The worm writes persistence into .claude/settings.json and .vscode/tasks.json, so it survives deleting node_modules and reinstalling clean packages.
- The two hook files point at each other: the Claude Code SessionStart hook runs .vscode/setup.mjs, and the VS Code folderOpen task runs .claude/setup.mjs.
- Rotate credentials last, not first. The payload installs a revocation watcher, so revoking a stolen token can trigger an attacker-controlled handler on your machine.
- keyv 6.0.0 (roughly 604M monthly installs) was the entry point. The worm then republished itself across hundreds of packages using stolen npm tokens.
- npm 12 does not run preinstall hooks by default. Every earlier npm client, and most CI images, still do.
You found the bad package. You deleted node_modules, cleared the npm cache, pinned keyv back to 5.6.0, and rebuilt. Reasonable. That is the standard playbook for a malicious dependency, and for most of them it works.
It does not work here. The August 2026 keyv compromise is a self-propagating npm worm that steals developer credentials, but the part worth your attention is where it hides. Before it finishes, it writes two small configuration files into your repository: one for Claude Code and one for VS Code. Those files are not in node_modules. They are in your working tree, they look like ordinary project config, and on many teams they get committed and pushed.
This report is built entirely on public research from JFrog, Aikido, SafeDep, Socket, Semgrep, OX Security and Phoenix Security, all verified against the primary write-ups. O3 Security did not independently detect this campaign, and nothing here is presented as first-party telemetry. Package counts differ between vendors because they count different things, so they are attributed individually below rather than merged into one number.
What actually happened
On August 4, 2026, an attacker gained control of the GitHub account of the maintainer behind keyv, a key-value storage library. The same maintainer also publishes cacheable, flat-cache, file-entry-cache, cacheable-request and the @cacheable/* family. That is a large blast radius for one account: these are caching utilities that sit deep in the dependency trees of tools most teams never think about, including eslint.
The attacker pushed malicious code to the main branch and cut a release immediately. This detail matters more than it first appears. Because the release ran through the project's normal GitHub Actions pipeline, the poisoned versions were published to npm with valid provenance, signed by GitHub Actions. Every trust signal npm gives you was intact. The supply chain worked exactly as designed, and it shipped malware.
The initial access path is not known. No vendor has published how the maintainer's account was taken over, and no threat actor has been named. Researchers place it in the Shai-Hulud family based on payload similarity, not attribution.
| Package | Ecosystem | Malicious Version | Monthly Installs | Clean Version To Pin |
|---|---|---|---|---|
| keyv | npm | 6.0.0 | 604M | 5.6.0 |
| flat-cache | npm | 6.1.24 | 580M | 6.1.23 |
| file-entry-cache | npm | 11.1.6 | 571M | prior 11.x |
| cacheable-request | npm | 13.0.20 | 137M | prior 13.x |
| @cacheable/utils | npm | 2.5.1 | 34M | prior 2.x |
| cacheable | npm | 2.5.1 | 30M | prior 2.x |
| @cacheable/memory | npm | 2.2.1 | 28M | prior 2.x |
| cache-manager | npm | 7.2.10 | 16M | 7.2.9 |
| @cacheable/node-cache | npm | 3.1.2 | 6M | prior 3.x |
| ecto | npm | 5.0.1 | 4.5K | prior 5.x |
| @cacheable/net | npm | 2.1.1 | 3.7K | prior 2.x |
From there it spread on its own. Secondary infections reached packages maintained by real companies: @deliveroo/reevent, @or-sdk/invitations, @picsart/ai-sdk, @qlik/embed-runtime and picasso.js among them. SafeDep watched the worm move between organizations every two to seven minutes and finish a cross-organization publishing burst in about half an hour.
The hooks: persistence that outlives your cleanup
Here is the mechanism that makes this campaign different from every other npm stealer. Alongside the credential theft, the payload commits five files into repositories it can reach. Two of them are the interesting ones.
The first is .vscode/tasks.json. VS Code supports tasks with a runOn property set to folderOpen, which means the task runs when someone opens the folder. The worm defines one called "Environment Setup" that executes node .claude/setup.mjs.
The second is .claude/settings.json. Claude Code supports a SessionStart hook that runs a shell command when a session begins. The worm registers one that executes node .vscode/setup.mjs.
“The Claude Code hook runs the file in .vscode. The VS Code task runs the file in .claude. Clean up one directory and the other still fires.”
Read those two sentences again and notice the crossover. Each hook executes a dropper living in the other tool's directory. If you find the Claude Code hook and delete the .claude directory, the VS Code task in .vscode still runs and re-establishes everything. If you find the VS Code task and delete .vscode, the Claude Code hook does the same in reverse. Partial cleanup rebuilds the infection. You have to remove both, in the same pass, before either tool is opened again.
For reference, this is the shape of a Claude Code SessionStart hook, per the official documentation. The malicious version is structurally the same, with a command that runs the dropper:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "/path/to/script.sh",
"timeout": 30,
"statusMessage": "Loading project context..."
}
]
}
]
}
}The SessionStart matcher fires on startup, resume, clear, compact and fork. In practice that means it runs when a developer opens the project and starts working, which is a reliable trigger on any machine where the repository has been cloned.
Workspace trust is a real mitigation, not a guarantee. VS Code blocks automatic tasks in an untrusted workspace and prompts before allowing them. Claude Code applies workspace trust to project-supplied settings, and since v2.1.218 project hooks no longer run from folders you have not trusted. The problem is that developers trust their own repositories by reflex. If the hooks were committed and pushed, the next person to clone and click through the prompt runs them.
This technique is not new to this campaign. Semgrep documented identical Claude Code and VS Code hooks, and the same Bun 1.3.13 download pattern, in the April 2026 compromise of the lightning package on PyPI. The tooling is being reused across ecosystems.
How the payload runs
The entry point is an npm lifecycle script. The attacker adds one line to package.json:
"scripts": {
"preinstall": "node setup.mjs"
}That is the whole trigger. Run npm install on an affected version and setup.mjs executes before anything else, with your user's permissions. What follows breaks into six steps:
- setup.mjs runs as a preinstall hook. It is a heavily obfuscated dropper, and by itself it does almost nothing suspicious.
- It checks whether the Bun runtime is present. If not, it downloads Bun 1.3.13 from the official oven-sh/bun GitHub releases URL and extracts it.
- It executes the real payload, a compiled 727,680-byte bundle shipped as math_init.js or Math_Symbol.js, using Bun rather than Node.
- The payload harvests credentials from files, environment variables, cloud metadata endpoints and running processes.
- It encrypts the loot with an embedded RSA public key and uploads it to a newly created public GitHub repository, falling back to a hardcoded C2 endpoint.
- It propagates: republishing poisoned packages with stolen npm tokens, committing hooks into reachable GitHub repositories, and stealing GitHub Actions secrets.
Why download Bun at all
Node is already on the machine. Downloading a second JavaScript runtime is extra work and extra noise, so the attacker had reasons. Bun runs a single compiled bundle with no dependency resolution and no npm chatter. It comes from github.com, a domain on essentially every corporate network allowlist, over plain HTTPS. And endpoint tooling that watches node for suspicious behaviour often has nothing to say about a binary called bun. The download is a legitimate file from a legitimate project, fetched from its legitimate release URL. Nothing in that sentence trips a signature.
What it steals
The collection scope is unusually broad. It takes npm tokens from .npmrc and GitHub tokens in every form: ghp_, gho_ and ghs_. It reads AWS credentials from ~/.aws/credentials, then queries EC2 and ECS metadata endpoints and enumerates Secrets Manager. It pulls Kubernetes service account tokens and namespace secrets. It extracts HashiCorp Vault tokens and walks the KV store via /v1/sys/mounts. It also grabs Stripe and Slack keys, SSH and PEM private keys, browser credential stores, Terraform state, Docker configs, and /etc/shadow where readable. Aikido counted roughly 200 glob patterns in the filesystem sweep.
The credential sweep explicitly targets AI tool configuration, including OpenAI, Anthropic, Claude and Cursor credentials, alongside .claude/settings.json. Developer AI tooling is now a first-class target for credential theft, not an afterthought.
On GitHub Actions runners it goes further. It reads runner memory for ACTIONS_ID_TOKEN_REQUEST_TOKEN, the OIDC token used to mint short-lived cloud credentials. Given a token with the workflow scope, it does something neater still. It creates a branch called dependabot/github_actions/format/setup-formatter. It injects a workflow that assigns ${{ toJSON(secrets) }} to an environment variable. It uploads the result as an artifact named format-results. Then it deletes the workflow and the branch. Your entire Actions secret store, exfiltrated, with the evidence cleaned up behind it.
The revocation watcher: why you should not rotate first
Standard incident response says rotate credentials immediately. For this payload, doing that first is a mistake.
SafeDep found that the malware installs a token-revocation watcher, a dead man's switch. It monitors whether the stolen GitHub token is still valid. Revocation is the trigger. When you revoke, the watcher notices and runs an attacker-supplied handler on the machine that is still infected. You perform the single most obvious remediation step and hand the attacker a signal plus code execution.
Sequence matters. Hunt and remove the watcher and both hook files first, from every affected machine and every branch. Rotate credentials only after the hosts are clean or rebuilt. Rotating on a live infected host is what the payload is waiting for.
Indicators of compromise
| Type | Indicator | Notes |
|---|---|---|
| SHA-256 | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc | math_init.js / Math_Symbol.js payload. |
| SHA-256 | 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 | setup.mjs dropper. |
| SHA-256 | fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb | setup.mjs, community-spread variant. |
| SHA-256 | 927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f | malicious .vscode/tasks.json. |
| SHA-256 | 14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128 | malicious .claude/settings.json. |
| SHA-256 | 3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7 | injected GitHub Actions workflow. |
| SHA-256 | 29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7 | Actions runner memory scraper. |
| Domain | npm-cache[.]com:443/router | C2 and exfiltration fallback, registered 2026-05-22. |
| URL | github.com/oven-sh/bun/releases/download/bun-v1.3.13/ | legitimate Bun release, abused by the dropper. |
| Ethereum contract | 0xE1f2395ee43e45A1556EC6438a88c31B83493103 | resilient C2 address resolution, selector 0x53ed5143. |
| Ethereum RPC | eth-mainnet.nodereal[.]io | used to read the contract. |
| File | setup.mjs, math_init.js, Math_Symbol.js | dropper and payload in package root. |
| File | .claude/settings.json, .claude/setup.mjs, .claude/math_init.js | Claude Code persistence. |
| File | .vscode/tasks.json, .vscode/setup.mjs | VS Code persistence. |
| File | .github/workflows/codeql_analysis.yml, format-results.txt | Actions secret theft, later deleted. |
| File | /tmp/tmp.dpkg_14527.lock | execution guard / lock artifact. |
| Git branch | dependabot/github_actions/format/setup-formatter | created for secret exfiltration. |
| Git commit | "chore: update config" | forged Co-authored-by: claude trailer. |
| Git commit | "Add CodeQL Analysis" | committed as github-advanced-security[bot]. |
| String | Shai-Hulud: Here We Go Again | description on exfiltration repositories. |
| String | thebeautifulmarchoftime, thebeautifulsnadsoftime | payload markers. |
| String | IfYouBlockThisAPIKeyItWillCrashTheLiveProduction... | embedded anti-blocking taunt. |
One note on the exfiltration repositories. Researchers counted somewhere between 546 and roughly 1,300 public GitHub repositories carrying the "Shai-Hulud: Here We Go Again" description and a results/ directory. Those are staging artifacts, not a victim count. Do not read them as 1,300 breached organizations.
Detection: what to run right now
Start with the hooks, because they are the part that persists. Run this from the root of any repository your team has cloned or built since August 4, 2026:
# 1. The two persistence files. Presence alone is not proof, but read them.
find . -path ./node_modules -prune -o \
\( -name 'tasks.json' -path '*/.vscode/*' -o -name 'settings.json' -path '*/.claude/*' \) -print
# 2. Droppers that should never exist in these directories.
find . -path ./node_modules -prune -o \
\( -name 'setup.mjs' -o -name 'math_init.js' -o -name 'Math_Symbol.js' \) -print
# 3. The specific hook wiring: each file pointing at the other's directory.
grep -rn --include='tasks.json' --include='settings.json' \
-e 'folderOpen' -e 'SessionStart' -e '.claude/setup.mjs' -e '.vscode/setup.mjs' . 2>/dev/null
# 4. Hook files modified on or after the campaign date.
find . -path '*/.vscode/tasks.json' -newermt '2026-08-04'
find . -path '*/.claude/settings.json' -newermt '2026-08-04'
# 5. Execution guard artifact.
ls -la /tmp/tmp.dpkg_14527.lock 2>/dev/null
# 6. Payload hashes anywhere on disk.
find . -name '*.mjs' -o -name '*.js' | while read -r f; do
h=$(sha256sum "$f" | cut -d' ' -f1)
case "$h" in
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc|\
54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668|\
fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb)
echo "MALICIOUS: $f ($h)" ;;
esac
doneThen check whether you ever resolved a poisoned version. Lockfiles are the source of truth here, not package.json:
# Did a known-malicious version ever get resolved?
grep -rnE 'keyv.*6\.0\.0|flat-cache.*6\.1\.24|file-entry-cache.*11\.1\.6' \
package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null
grep -rnE 'cacheable-request.*13\.0\.20|cache-manager.*7\.2\.10|@cacheable/.*(2\.5\.1|2\.2\.1|3\.1\.2|2\.1\.1)' \
package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null
# What is actually installed right now?
npm ls keyv flat-cache file-entry-cache cacheable cache-manager 2>/dev/null
# Bun on a machine where nobody installed Bun is a strong signal.
which bun; ls -la ~/.bun 2>/dev/null; ls -d /tmp/bun-dl-* 2>/dev/nullOn GitHub, search your organization's audit log for the branch dependabot/github_actions/format/setup-formatter, commits with the message "chore: update config", any workflow named codeql_analysis.yml that was created and then deleted, and artifacts named format-results. A verified badge on those commits means nothing here: the worm committed using stolen, legitimately-issued credentials, so the signatures are real.
Response, in the right order
- Isolate first. Take affected developer machines and CI runners off the network. Preserve package tarballs, npm logs, CI logs and GitHub audit logs before you change anything.
- Remove both hook files together. Delete .claude/settings.json, .claude/setup.mjs, .claude/math_init.js, .vscode/tasks.json and .vscode/setup.mjs in a single pass, on every branch of every affected repository. Removing one and not the other rebuilds the infection.
- Find and remove the revocation watcher before touching any credential. This is the step people skip.
- Disable the release workflow and revoke the publishing credential for any package you own that may have been republished.
- Now rotate, in order. npm tokens with bypass_2fa enabled first. Then GitHub PATs, OAuth and Actions tokens. Then AWS, Azure, GCP, Kubernetes, Vault, database, SSH and VPN credentials. Then Stripe, Slack and AI service keys.
- Pin clean versions: keyv 5.6.0, flat-cache 6.1.23, cache-manager 7.2.9. Delete node_modules, clear the npm cache, and reinstall from a verified lockfile.
- Rebuild CI runners and developer machines from clean images. A credential sweep this broad means you cannot enumerate what was taken.
- Review npm trusted publishers and GitHub OIDC configuration, including release-drafter automation, before you publish again.
Treat any machine that ran an affected version as credential-exposed, not merely suspicious. Socket's guidance is blunt about this, and it is the right call. The payload sweeps roughly 200 glob patterns before you notice anything, so an inventory of what it definitely got is not something you can produce.
Why scanners missed this
Five specific gaps, each worth naming precisely rather than waving at.
- Provenance was valid. The malicious versions were signed by GitHub Actions through the project's real release pipeline. Tooling that checks provenance and signatures returned a pass, because the compromise happened upstream of signing.
- Version reputation was clean. keyv 6.0.0 was a major release from a maintainer with years of history and hundreds of millions of installs. Heuristics that flag new packages or unknown publishers had nothing to fire on.
- The dropper is not the payload. setup.mjs downloads a real Bun binary from github.com. Nearly every network allowlist permits that domain. Static SCA sees only an obfuscated file with a fetch in it. The malicious 727,680-byte bundle never appears in the published tarball at all.
- Bun sidesteps Node-centric telemetry. EDR rules tuned to suspicious node behaviour do not necessarily cover a bun process reading ~/.aws/credentials.
- The hooks are not dependencies. .claude/settings.json and .vscode/tasks.json are project configuration. SCA scans your dependency tree, not your editor config, so nothing in a conventional pipeline looks at those files at all.
That last gap is the durable lesson. As AI coding tools gain hook systems, the set of files that can silently execute code on a developer's machine has grown well beyond package manifests. Attackers noticed in April, on PyPI. They reused it in August, on npm.
MITRE ATT&CK mapping
| ID | Technique | How it appears here |
|---|---|---|
| T1195.002 | Supply Chain Compromise: Compromise Software Supply Chain | Poisoned npm releases published with valid provenance. |
| T1546 | Event Triggered Execution | Claude Code SessionStart hook and VS Code folderOpen task. |
| T1552.001 | Unsecured Credentials: Credentials In Files | .npmrc, ~/.aws/credentials, .env, PEM and SSH keys. |
| T1195 | Supply Chain Compromise | Worm republishing across organizations with stolen tokens. |
T1546 is cited at the parent level deliberately. ATT&CK has no sub-technique that cleanly covers IDE and AI-assistant configuration files as a persistence trigger. T1546.018 covers Python startup hooks, which is what the April PyPI variant of this campaign abused, but it does not describe the npm hook behaviour.
How many packages, really
You will see very different totals depending on which write-up you read. They are not contradicting each other so much as counting different objects, and combining them produces nonsense.
| Vendor | Reported scope | What is being counted |
|---|---|---|
| Aikido | 868 packages / 1,381 versions | Package names, higher name count than SafeDep. |
| SafeDep | 1,684 versions / 420 names, 9 organizations | Verified poisoned versions, revised up from 353/79. |
| Socket | 868+ packages | Independent verification, separate methodology. |
| OX Security | 440+ packages, 2B+ monthly downloads | Affected packages at time of writing. |
All of these count malicious artifacts, not victims. Nobody has published a figure for how many organizations actually installed a poisoned version, and you should be sceptical of anyone who claims one.
What to change after this one
The tactical fix is npm 12, where preinstall lifecycle hooks do not run by default. That closes the entry point used here. It does not close the hook persistence, and if a poisoned version is already in your tree, upgrading the client preserves the exposure rather than removing it. Upgrade, but do not treat it as remediation.
The structural fixes are less exciting and more useful. Disable install scripts by default. Allowlist the handful of packages that genuinely need them. Treat .claude/settings.json, .vscode/tasks.json and equivalent AI tool config as executable code in review, because that is what they are. Watch for them showing up in diffs where nobody meant to add them. And keep an SBOM with resolved versions. When the next advisory lands, the only question that matters is whether a specific version ever entered your tree. Lockfile archaeology across dozens of repos at incident speed is not a plan.
One maintainer account produced a worm that reached hundreds of packages and two billion monthly installs in a single day. The uncomfortable part is not that it happened. It is that every automated trust signal, provenance, signing, publisher reputation and version history, said the packages were fine.