Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
Home/Blog/Claude Code and VS Code Hooks: How the keyv npm Worm Persists After You Delete It
Threat ResearchAugust 4, 202614 min read

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.

O
O3 Security Team
Claude Code and VS Code Hooks: How the keyv npm Worm Persists After You Delete It
Key takeaways
  • 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.

Key takeaway

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.

PackageEcosystemMalicious VersionMonthly InstallsClean Version To Pin
keyvnpm6.0.0604M5.6.0
flat-cachenpm6.1.24580M6.1.23
file-entry-cachenpm11.1.6571Mprior 11.x
cacheable-requestnpm13.0.20137Mprior 13.x
@cacheable/utilsnpm2.5.134Mprior 2.x
cacheablenpm2.5.130Mprior 2.x
@cacheable/memorynpm2.2.128Mprior 2.x
cache-managernpm7.2.1016M7.2.9
@cacheable/node-cachenpm3.1.26Mprior 3.x
ectonpm5.0.14.5Kprior 5.x
@cacheable/netnpm2.1.13.7Kprior 2.x
Initial compromised packages (install counts as reported by Aikido)

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.
The cross-referencing trick

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:

.claude/settings.json (structure of a SessionStart hook)
{
  "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.

Watch out

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:

package.json (injected)
"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:

  1. setup.mjs runs as a preinstall hook. It is a heavily obfuscated dropper, and by itself it does almost nothing suspicious.
  2. 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.
  3. 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.
  4. The payload harvests credentials from files, environment variables, cloud metadata endpoints and running processes.
  5. 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.
  6. 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.

By the numbers

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.

Watch out

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

TypeIndicatorNotes
SHA-2569fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bccmath_init.js / Math_Symbol.js payload.
SHA-25654dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668setup.mjs dropper.
SHA-256fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1ebsetup.mjs, community-spread variant.
SHA-256927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1fmalicious .vscode/tasks.json.
SHA-25614eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128malicious .claude/settings.json.
SHA-2563f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7injected GitHub Actions workflow.
SHA-25629ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7Actions runner memory scraper.
Domainnpm-cache[.]com:443/routerC2 and exfiltration fallback, registered 2026-05-22.
URLgithub.com/oven-sh/bun/releases/download/bun-v1.3.13/legitimate Bun release, abused by the dropper.
Ethereum contract0xE1f2395ee43e45A1556EC6438a88c31B83493103resilient C2 address resolution, selector 0x53ed5143.
Ethereum RPCeth-mainnet.nodereal[.]ioused to read the contract.
Filesetup.mjs, math_init.js, Math_Symbol.jsdropper and payload in package root.
File.claude/settings.json, .claude/setup.mjs, .claude/math_init.jsClaude Code persistence.
File.vscode/tasks.json, .vscode/setup.mjsVS Code persistence.
File.github/workflows/codeql_analysis.yml, format-results.txtActions secret theft, later deleted.
File/tmp/tmp.dpkg_14527.lockexecution guard / lock artifact.
Git branchdependabot/github_actions/format/setup-formattercreated 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].
StringShai-Hulud: Here We Go Againdescription on exfiltration repositories.
Stringthebeautifulmarchoftime, thebeautifulsnadsoftimepayload markers.
StringIfYouBlockThisAPIKeyItWillCrashTheLiveProduction...embedded anti-blocking taunt.
IOCs for the August 2026 keyv / Shai-Hulud npm campaign

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:

check-for-hooks.sh
# 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
done

Then check whether you ever resolved a poisoned version. Lockfiles are the source of truth here, not package.json:

check-lockfiles.sh
# 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/null

On 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

  1. 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.
  2. 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.
  3. Find and remove the revocation watcher before touching any credential. This is the step people skip.
  4. Disable the release workflow and revoke the publishing credential for any package you own that may have been republished.
  5. 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.
  6. 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.
  7. Rebuild CI runners and developer machines from clean images. A credential sweep this broad means you cannot enumerate what was taken.
  8. Review npm trusted publishers and GitHub OIDC configuration, including release-drafter automation, before you publish again.
Tip

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

IDTechniqueHow it appears here
T1195.002Supply Chain Compromise: Compromise Software Supply ChainPoisoned npm releases published with valid provenance.
T1546Event Triggered ExecutionClaude Code SessionStart hook and VS Code folderOpen task.
T1552.001Unsecured Credentials: Credentials In Files.npmrc, ~/.aws/credentials, .env, PEM and SSH keys.
T1195Supply Chain CompromiseWorm republishing across organizations with stolen tokens.
Techniques observed in this campaign

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.

VendorReported scopeWhat is being counted
Aikido868 packages / 1,381 versionsPackage names, higher name count than SafeDep.
SafeDep1,684 versions / 420 names, 9 organizationsVerified poisoned versions, revised up from 353/79.
Socket868+ packagesIndependent verification, separate methodology.
OX Security440+ packages, 2B+ monthly downloadsAffected packages at time of writing.
Reported scope by vendor, as published

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.

Frequently asked questions

Is deleting node_modules enough to remove the keyv worm?

+
No. The payload writes persistence into .claude/settings.json and .vscode/tasks.json, which live in your working tree rather than node_modules. Deleting node_modules and reinstalling clean packages leaves both hooks in place. They re-execute the next time someone opens the project in VS Code or starts a Claude Code session.

Which keyv version is safe to use?

+
Pin keyv to 5.6.0. The malicious release is 6.0.0. If you need the v6 API, 6.0.0-rc.1 predates the compromise. Also pin flat-cache to 6.1.23 and cache-manager to 7.2.9, and check your lockfile for the @cacheable/* family rather than trusting package.json alone.

Why should I not rotate credentials immediately?

+
The malware installs a token-revocation watcher that acts as a dead man's switch. Revoking a stolen GitHub token is the trigger: it runs an attacker-supplied handler on the still-infected machine. Remove the watcher and both hook files first, then rotate once the host is clean or rebuilt.

Does npm 12 protect me from this attack?

+
Partially. npm 12 does not run preinstall lifecycle hooks by default, which blocks the initial execution path. But it does not remove hooks already written into .claude or .vscode, and upgrading the client while a poisoned version sits in your tree preserves the exposure. Every npm client before 12 remains fully exposed.

How do the Claude Code and VS Code hooks work together?

+
They cross-reference each other. The Claude Code SessionStart hook in .claude/settings.json runs node .vscode/setup.mjs, while the VS Code folderOpen task in .vscode/tasks.json runs node .claude/setup.mjs. Deleting only one directory leaves the other able to restore the infection, so both must be removed in the same pass.

Who is behind the keyv npm compromise?

+
Unattributed. No threat actor has been named and the initial access path for the maintainer's GitHub account is unknown. Researchers at Aikido and Semgrep place it in the Shai-Hulud family based on payload similarity to the April 2026 PyPI lightning compromise, which used identical hooks and the same Bun 1.3.13 pattern.

See your full attack chain.
Code, build, runtime. One platform.