

At 09:00 UTC this morning, an attacker with control of a single npm maintainer account published keyv@6.0.0. Twelve hours later, the same payload was sitting inside hundreds of unrelated packages — over 400 package names across more than 1,700 versions.
The library code did not change. Not one byte of dist/ was modified. The entire attack lives in four lines of package.json and two files that most dependency scanners never open.
And unlike the Shai-Hulud waves of September 2025 and November 2025, this one was built with AI coding agents explicitly in the blast radius. The payload plants an autostart hook in .claude/settings.json and a folderOpen task in .vscode/tasks.json, so that simply opening a cloned repository — by a developer or by an agent — is enough to detonate it. No npm install required.
No npm install required.This is the exact class of attack Operant built CodeInjectionGuard for. Below is what the malware actually does, why every cryptographic check in the supply chain passed while it happened, and how Operant stops it in the two places it matters: at scan time, before a local MCP server's dependency ever installs, and at runtime, on endpoints and in the cloud, when something already inside the environment tries to execute it.
Credit where it's due: the technical analysis below draws on the Socket Threat Research Team's writeup, with corroborating detail from the JFrog Security Research team. This is an active, still-unfolding incident.
The compromised maintainer owns keyv, cacheable, flat-cache, file-entry-cache, cacheable-request, cache-manager, and the @cacheable/* scope — tens of millions of weekly downloads between them. Most affected teams have never typed any of these names. They arrive four levels deep, underneath ESLint, underneath got, underneath your build tooling.
The trojanized package.json adds two files and one hook:
"files": [ "dist", "LICENSE", "setup.mjs", "Math_Symbol.js" ],
"scripts": {
"preinstall": "node setup.mjs"
}
That's it. The published dist/ output is byte-identical to the last clean release, so the package behaves perfectly normally after installation — while the host is already compromised. Any tool that diffs library code, checks for known CVEs, or looks at what the module exports sees nothing at all.
setup.mjs, the Bun loadersetup.mjs exists to get a different runtime onto the box. If bun isn't already present, it downloads a platform-matched standalone Bun 1.3.13 binary straight from the official GitHub releases CDN — fingerprinting architecture and libc along the way, including musl and Alpine detection, and unpacking with the system unzip, PowerShell Expand-Archive on Windows, or a hand-written pure-JavaScript ZIP parser when neither exists. Then:
execFileSync(bunBinary, [payloadPath], { stdio: "inherit", cwd: D });
This step is the point of the whole design. Running the payload under a freshly downloaded Bun binary sidesteps the host's Node version entirely, along with any Node-level instrumentation, NODE_OPTIONS hooks, or runtime monitoring the organization has bolted onto its Node processes. The malicious code executes in a runtime your security tooling has never seen and does not instrument. Fetching from github.com also means no suspicious C2 domain shows up in egress logs at the moment of compromise.
It's worth being precise about the obfuscation, because it is the single highest-signal property of this file — and it is not, in the usual sense, part of any vulnerability. There is no vulnerability in keyv. No flaw was exploited, no CVE will ever be issued. A trusted maintainer account was stolen and used to ship malware through a completely legitimate, documented npm feature. That distinction is why SCA and SAST both came back clean: neither one is looking for betrayal, they're looking for bugs. The preinstall hook is the attack mechanism. The obfuscation is evasion, aimed squarely at the only two reviewers standing in the path — a human skimming a diff, and a scanner doing string extraction.
Which is exactly why it stands out. Legitimate lifecycle scripts are never obfuscated, because there is no reason to obfuscate them. Minification protects bandwidth in browser bundles; obfuscation protects IP in shipped client code. An install hook is neither — it runs once, on a build machine, and is served to no one. Real install scripts are conspicuously boring: node-gyp rebuild, a platform check that fetches the right prebuilt binary for sharp, a postinstall that prints a funding notice. Nor is this what minified code looks like. Minifiers shorten identifiers and strip whitespace; they do not emit hex-encoded string arrays fed through a rotating decoder function, and they do not generate a while(!![]) self-defending array-shuffle loop. That construct is the recognizable output of a dedicated JavaScript obfuscator, and it has no honest use in a caching library's install hook.
Math_Symbol.js is a ~710 KB Bun bundle protected with polymorphic basE91 encoding — one shared opcode table driving dozens of per-scope alphabets, decoded lazily, so static string extraction returns nothing useful. Decoded, its internal log tags name the components: [collector], [dispatcher], [provenance], [publish].
The collector takes everything a developer laptop or CI runner holds: cloud instance metadata (169.254.169.254, including the IMDSv2 token dance, and 169.254.170.2 for ECS task roles), AWS credential chains and Secrets Manager across all regions, GCP service account private keys, Azure client secrets, HashiCorp Vault tokens, Kubernetes service account tokens from /var/run/secrets/kubernetes.io/serviceaccount/token, GitHub Actions OIDC tokens plus organization and repository secrets, npm tokens, and a TruffleHog-style regex sweep across disk for loose keys, bearer tokens, and PEM private key blocks.
Then it propagates. Using the stolen npm identity, the payload calls the registry's whoami, discovers targets through maintainer search, and mints publish credentials through the OIDC token exchange endpoint. For every reachable package it downloads the tarball, injects the same preinstall hook and both payload files, recomputes the integrity and shasum fields, bumps the version, and PUTs it back. No fixed target list is required: one stolen token with broad write access turns every package that publisher owns into the next wave, and the cross-organization publishing burst played out over minutes, not days.
Exfiltration deliberately avoids a fixed C2. A GitHubSender component creates repositories and commits stolen findings through the GraphQL API — roughly 1,300 public repos now carry the campaign's calling-card description, "Shai-Hulud: Here We Go Again." A DomainSender component exfiltrates over DNS. The fallback endpoint is resolved dynamically from an Ethereum smart contract, so infrastructure rotates without ever touching the payload.
The source repository doesn't just carry the npm hook. It plants a SessionStart hook in .claude/settings.json and a folderOpen task in .vscode/tasks.json. Both execute the same loader when a developer or an AI coding agent opens the cloned repository. There is no install step anywhere in that chain. An agent told to "clone this repo and figure out why the tests are failing" is sufficient.
This is the part security teams need to sit with.
keyv@6.0.0 shipped with valid npm provenance, signed by GitHub Actions. Not forged — genuinely valid. The attacker pushed to main and let the project's own legitimate release workflow build the already-trojanized source. Socket's summary is the line worth pinning above the desk: provenance attests to build integrity, not source integrity.
So:
npm update, CI cache misses, and caret ranges all resolved forward into the poisoned versions.npx had no protection at all. More on that in a moment.One genuine mitigation exists and deserves mention: on npm 12 and newer, preinstall lifecycle hooks do not run by default, so the loader never fires during install. That is real defense-in-depth and worth upgrading for today. It does nothing for the .claude and .vscode autostart paths, and nothing for the enormous installed base still on npm 10 and 11.
Open any developer's .mcp.json, claude_desktop_config.json, or Cursor MCP settings and count the lines that look like this:
{ "command": "npx", "args": ["-y", "@some-vendor/mcp-server"] }
This pattern is now the default way local stdio MCP servers are distributed, and it has three properties that make Shai-Hulud 3.0 almost purpose-built for it:
npx -y resolves latest at invocation time. There is no lockfile. Every agent session is a fresh dependency resolution against the live registry.preinstall fires before a single line of the MCP server's own code is evaluated.keyv and flat-cache are transitive dependencies of enormous swaths of Node tooling. An MCP server four dependencies removed from ESLint is an MCP server that pulls keyv.
Operant's MCP Gateway and Endpoint Protector treat every MCP server — local stdio or remote — as an untrusted supply chain artifact that must be resolved and inspected before it is allowed to install. When a new or changed MCP server dependency appears, CodeInjectionGuard runs the following investigation:

1. Resolve, don't install. Operant fetches the package tarball from the registry into an isolated staging area. Nothing is unpacked into node_modules. No lifecycle script has executed yet. This is the critical ordering decision — the scan happens before npm's preinstall stage, not alongside it.
2. Read the manifest for lifecycle hooks. Operant parses package.json and flags any preinstall, install, postinstall, or prepare script. For keyv@6.0.0, "preinstall": "node setup.mjs" trips this immediately. A lifecycle hook is not itself malicious — plenty of legitimate packages compile native modules — so this is a routing signal, not a verdict.
3. Reconcile the hook against the file manifest. The hook references setup.mjs. Operant checks the files array and finds two entries that were not in the previous clean release: setup.mjs and Math_Symbol.js. Meanwhile dist/ is byte-identical to the prior version. A release that changes no library code but adds an executable preinstall entrypoint is, by itself, a high-severity structural anomaly.
4. Extract and analyze the hook target. Operant pulls setup.mjs out of the staged tarball and submits its contents to the code injection classifier. This is where the analysis stops being heuristic. The scanner evaluates the actual behavior encoded in the script:
execFileSync of a second local file using that downloaded runtime5. Return a verdict and enforce it. The classifier returns code_prompt_injection with an attack subclass of context manipulation, mapped to LLM03:2025 Supply Chain, criticality HIGH. The install is blocked. The package never reaches node_modules. setup.mjs never executes. Bun is never downloaded. Math_Symbol.js is never unpacked, and the credential collector never runs.
The MCP server fails to start with a clear reason, and the finding lands in the Operant console:

Operant flags the setup.mjs loader as code_prompt_injection — LLM03:2025 Supply Chain, criticality HIGH. The explanation field records the reasoning: obfuscated JavaScript written to conceal its true functionality from both human reviewers and AI assistants. The classifier does not grade the obfuscation on a curve. In a lifecycle hook, its mere presence is the finding.
Note what the detected prompt actually contains — the hex-array preamble of the loader itself:
#!/usr/bin/env node (function(e,f){const wS={e:'0xb8',f:'@JTg',M:'0x5',J:'0x87',
F:'^6ZM',N:'0x3f',g:'0x61',O:'0xc9',P:'LeMK',R:'0x2a',A:'P]$u',q:0xc};
function Y(e,f){return i(e- -'0x2b4',f);}const M=e();while(!![]){try{const J=-parseInt(Y(-
No signature matched this. No CVE existed for it. The verdict came from reasoning about what the code does, on a file that was published less than three hours earlier.
Scan-time blocking covers the install path. It does not cover the paths this campaign deliberately built to avoid installs: the .claude SessionStart hook, the .vscode folderOpen task, an already-poisoned node_modules restored from a CI cache, or a base image built yesterday from a lockfile that was clean at the time.
For those, Operant enforces at the point of execution.
On the endpoint, Agent Protector and Endpoint Protector watch what the agent and its child processes actually do. The campaign's own behavior gives it away:
In the cloud, 3D Runtime Defense and AI Gatekeeper cover the CI runner and the Kubernetes workload, where the credential blast radius is widest. Two signals are decisive and both are cheap to enforce:
Here is the runtime decision for the same artifact:

Live request event, Claude-agent+engineering-triage@operant.ai → claude.ai. The scanner completes, the setup.mjs loader is classified as code_prompt_injection, and the detection chain resolves to block. The request carries a full trace ID, so the finding is joinable to the rest of the session's activity.
That second screenshot is worth dwelling on, because of which agent it is. This is an engineering triage agent — the kind of agent whose entire job is to read repositories, read logs, and read files that humans handed it. The loader reached the agent as content to be analyzed, and it was blocked before the model ever reasoned over it.
The .claude/settings.json hook is the cheapest component of this entire campaign and the hardest for dependency tooling to catch — because it isn't a dependency. It's a config file in a repository. No registry, no lockfile, no integrity hash, no npm install. Clone the repo, open it, done.
That path exists because agent configuration is, by design, layered and partly repository-supplied. Claude Code resolves settings across four scopes in strict priority order: Managed (highest, cannot be overridden by anything), then command-line arguments, then Local (.claude/settings.local.json), then Project (.claude/settings.json), then User (~/.claude/settings.json).
Look at where the attacker's hook lands. Project scope — the one scope on the machine that an attacker controls simply by getting a file into a git repository, and the scope that gets shared with every collaborator who clones. That's the whole trick.
The single highest-value setting for this attack is a managed-only key:
{
"allowManagedHooksOnly": true
}
With this deployed, only managed hooks, SDK hooks, and hooks from plugins force-enabled in managed enabledPlugins are loaded. User, project, and all other plugin hooks are blocked outright. The planted SessionStart hook lands in project scope, project hooks don't load, and the loader never fires — no scanning required, no detection latency, no verdict to get right. The attack path is simply closed. (disableAllHooks is the blunter version if your organization uses no hooks at all.)
On the VS Code side, the equivalent control is Workspace Trust: folderOpen tasks do not execute automatically in an untrusted workspace. The failure mode is habitual — developers who reflexively click "Trust" on every cloned repository have opted themselves out of the protection.
Managed settings are prevention. Operant is the layer underneath it — catching the attempt, and catching the machine the policy didn't reach.
Treat the agent config surface as sensitive. Operant monitors writes to .claude/settings.json, .claude/settings.local.json, .mcp.json, and .vscode/tasks.json. A file that gains a SessionStart hook or a new stdio server is a finding regardless of how it got there — git clone, an npm lifecycle script, or the agent itself. On a machine with allowManagedHooksOnly set, that write is harmless and still worth an alert: it means someone handed your developer a poisoned repository, which is intelligence you want.
Block at execution if the hook does fire. A hook body is a shell command, and CodeInjectionGuard evaluates it exactly as it evaluated setup.mjs. A SessionStart hook that spawns node setup.mjs, pulls a foreign language runtime from a release CDN, and execFileSynces a local bundle under it is blocked on behavior — not because the file was recognized, but because that sequence has no legitimate reading. This is the control that covers the unmanaged laptop, and it is the same classifier and the same verdict shown in both screenshots above.
The honest framing: if you deploy allowManagedHooksOnly, Operant never has to make this call. That's the correct outcome, and it's why the recommendation comes before the product. Runtime enforcement is what you want underneath the policy, for the endpoints the policy didn't reach and the day someone ships a bypass.
Short version, and it is not "remove the package."
Every cryptographic control in the modern npm supply chain worked as designed today, and the attack succeeded anyway. Provenance was valid. The build was reproducible. The maintainer was real. The library code was unmodified. The only thing that changed was a four-line addition to a manifest and two files that no scanner had a reason to open.
Meanwhile the delivery path has quietly moved. It is no longer a developer typing npm install. It is an npx -y in an MCP config that resolves latest on every agent session, a coding agent cloning a repository to investigate a bug, an agent reading a file it was asked to summarize. These are trust relationships that did not exist eighteen months ago, and this campaign was written for them on purpose — the .claude hook is not an accident.
Static analysis scans code that already exists. This code did not exist three hours before it was running on thousands of machines. The defense has to sit where the trust is actually exercised: at the moment a package is resolved, and at the moment code is executed.
That's the whole thesis. Runtime is where the fight is.
CodeInjectionGuard is available today as part of Agent Protector, with MCP dependency scanning through MCP Gateway and cloud-side enforcement through 3D Runtime Defense. It integrates with Claude Code, Cursor, Copilot, LangChain, CrewAI, and custom agent architectures. Start a free trial today.