In January 2026, a security researcher at Aikido noticed something odd. An npm package called
react-codeshiftwas being requested hundreds of times a day. It had never existed. No human had typed it. A language model had invented the name by blending two real tools (jscodeshiftandreact-codemod), the hallucination got baked into a batch of AI agent instruction files, those files were forked across more than 230 repositories, and coding agents had been dutifully trying to install the phantom package ever since.
The researcher registered the name himself before anyone else could. That is the only reason it didn’t become an attack.
A few months later, attackers stopped waiting for luck. Over one 48-hour window in August, a single threat actor pushed more than 700 malicious packages to npm under AI-flavored, randomly generated names. No install script needed. A README tells the reader (human or agent) to require() the module, and that one line kicks off a cross-platform remote access trojan.
If you let an AI coding agent run npm install on your machine, you are the target of that campaign. This post is about the tool I built to stand between the agent and the registry.
Why agents are a softer target than people
Developers have always been exposed to typosquatting, so why is this worse?
An agent has no instinct for “that name looks wrong.” When a human developer sees a package they don’t recognize, they pause. They search it, check the GitHub stars, read the README. An agent reads its own output as ground truth and shells out.
Hallucinations are predictable. When researchers re-ran the same prompts, a large share of invented package names came back identically every time. An attacker doesn’t need to guess what a model will make up. They can just ask it, collect the recurring names, and register them first.
Hallucinated names aren’t typos. npm’s collision detection catches lodahs because it's close to lodash. It does nothing for react-codeshift, because that string is brand new. The Cloud Security Alliance documented the unused-imports case, a hallucinated stand-in for eslint-plugin-unused-imports that kept pulling hundreds of weekly downloads even after npm flagged it. The install succeeds cleanly and nobody gets an error.
Agents run with your privileges, unattended. In a CI pipeline or a dev container, the agent has your tokens, your SSH keys, your cloud credentials. By the time the diff shows up for review, the postinstall script has already run, or the package has already been imported during a test.
Verification has to move to the moment the install happens, not the moment the pull request is opened.
The design: gate the install, not the intent
You can’t prompt your way out of this. Telling the agent “please verify packages before installing” is a suggestion, and the whole problem is that the agent confidently believes it already did.
So the tool doesn’t talk to the model at all. It sits at the shell boundary. Every time something calls npm, npx, pnpm, or yarn with an install-like command, the request is intercepted, each new package is checked, and the command is either allowed to proceed, blocked, or held for a human.
There are four layers.
1. Allowlist first
If a package is already in your lockfile, or in a project-level allowlist.json, it passes immediately. This keeps the tool silent for 95% of real work. Agents mostly add dependencies you already use.
2. Registry metadata checks
For anything new, the tool queries the npm registry and scores the package on the signals that actually matter:
- Age. A package first published in the last 30 days is suspicious. Last 7 days, very suspicious.
- Publisher history. Does the maintainer have other packages? How old is the account? A brand-new account publishing an “eslint plugin” last Tuesday is a red flag no matter how many downloads it has.
- Repository resolution. Does the
repositoryfield point to a real, reachable GitHub repo whose package.json actually declares this name? - Version count and cadence. One version, published once, is different from a package with a year of releases.
- Install scripts.
preinstall,install, andpostinstallhooks are flagged and surfaced in the decision, even though, as the August campaign showed, their absence proves nothing.
Notice what is not on that list as a primary signal: download counts. Malicious packages in the wild have sustained hundreds of weekly downloads precisely because agents keep requesting them. As the Aikido team put it, what matters is who registered the package, when, and whether that matches a legitimate maintainer.
3. Lookalike and hallucination heuristics
The tool keeps a list of the top few thousand npm packages and computes edit distance against each candidate. It also checks for the specific conflation pattern models fall into: names that are a concatenation or splice of two popular packages. react-codeshift scores high on that check. So does unused-imports against eslint-plugin-unused-imports.
4. The decision
Each check contributes to a risk score. Below a low threshold, the install proceeds. Above a high threshold, it’s blocked outright and the agent gets a clear error message it can act on. In the middle, the command pauses and a human gets a one-line prompt:
BLOCKED: checkout-mobile-bnpl
first published: 2 days ago
publisher: 1 package, account created 2 days ago
repository: none
nearest known package: none (novel name)
risk: HIGH Approve once [y] / Add to allowlist [a] / Deny [N]:That prompt is the whole point. Instead of reading a 400-line diff looking for a dependency bump, you answer one question at the exact moment it matters.
What it looks like in practice
Installation is a single shim on your PATH, plus a hook for the agents that support them (Claude Code's pre-tool-use hooks and similar mechanisms in Cursor and Codex CLI). The shim handles the case where the agent runs a bare npm install; the hook handles the case where the agent tries to bypass the shim by invoking the binary directly.
Here is the core of the check, stripped down:
import { execSync } from "node:child_process";const REGISTRY = "https://registry.npmjs.org";
const DAY = 86_400_000;export async function assess(name) {
const res = await fetch(`${REGISTRY}/${encodeURIComponent(name)}`);
if (res.status === 404) return { risk: "BLOCK", reason: "package does not exist" }; const meta = await res.json();
const created = new Date(meta.time.created);
const ageDays = (Date.now() - created) / DAY;
const versions = Object.keys(meta.versions ?? {}).length;
const latest = meta.versions[meta["dist-tags"].latest];
const scripts = latest.scripts ?? {};
const hasLifecycle = ["preinstall", "install", "postinstall"].some(k => k in scripts);
const repo = latest.repository?.url ?? null; let score = 0;
if (ageDays < 7) score += 40;
else if (ageDays < 30) score += 20;
if (versions <= 1) score += 15;
if (!repo) score += 20;
if (hasLifecycle) score += 15;
if ((meta.maintainers ?? []).length === 0) score += 10; return {
risk: score >= 60 ? "BLOCK" : score >= 30 ? "ASK" : "ALLOW",
ageDays: Math.round(ageDays),
versions,
repo,
hasLifecycle,
score,
};
}
The real implementation adds the publisher lookup, the lookalike check, caching, and offline mode (fail closed when the registry is unreachable). But the shape is this simple, and it takes under 200ms per new package.
What this does not solve
I’d rather be clear than impressive.
It doesn’t catch a trusted package that turns malicious. If a maintainer’s account is compromised and version 4.2.1 ships a credential stealer, the package passes every age and publisher check. Lockfile pinning and hash verification are your defense there, and you should have them regardless.
It doesn’t inspect code. This is a provenance and heuristics tool, not a static analyzer. Pair it with a scanner that actually reads the tarball if you want that layer.
It can be annoying for legitimately new packages. Someone publishes a great new library this week and the tool asks you to confirm. That is the correct behavior. The prompt takes two seconds.
Determined agents can route around a shell shim. An agent with unrestricted shell access can download a tarball with curl and require() it. The hook integration closes some of that gap; running agents in a sandboxed container with network egress rules closes the rest. Treat autonomous package installation as a privileged operation, because it is one. Trend Micro's analysis makes the same case for a layered defense rather than any single control.
It protects one machine at a time. The open-source tool is something you install on your laptop or wire into a single pipeline. It doesn’t give a security team visibility across 200 developers and 40 repos, and it doesn’t stop the engineer who never ran init.
Here’s how to do it yourself
The open-source tool is Verdaccio — it is a private npm registry. Install it with:
npm install -g verdaccioThen you can ask AI to automatically block all installs from packages that have recorded vulnerabilities from osv.dev. Plain and simple!
For teams: use InstallSafe
The individual-developer version solves my problem. It doesn’t solve the problem of a CTO who has just rolled out coding agents to an entire engineering org and has no idea what those agents are pulling from npm.
That’s why I built InstallSafe. It takes the same gate-the-install model and makes it organization-wide:
- Centrally managed policy. One allowlist, one set of risk thresholds, enforced on every developer machine and every CI runner without relying on each engineer to configure a shim.
- Fleet-wide visibility. See every package any agent tried to install across your org, what was blocked, and who approved what.
- Automatic blocking. High-risk installs are stopped before they run, so the security team isn’t reviewing incidents after the postinstall script has already fired.
- Works with the agents you already use. Claude Code, Cursor, Codex CLI, and plain
npmin CI.
If you’re responsible for AI-assisted development at a company, installsafe.io is where to start.
I would still like to hear what the open-source tool catches on your projects, and especially what it misses. Open an issue, or find me on X.
Until then, treat every package name an AI hands you as a guess, not a fact.