Slopsquatting: stop AI installing bad packages
AI models invent package names, and attackers register them. Here is the defensive half nobody ships: an agent install-gate, lockfile pinning with hashes, and pre-install import checks.
Slopsquatting is a supply-chain attack that exploits a specific AI failure: language models routinely invent package names that do not exist, and attackers register those exact names on npm or PyPI with malicious code inside. When a coding agent then runs npm install <hallucinated-name>, it pulls the attacker's package. You defend against it with three controls the threat write-ups usually skip: an install-gate that stops your agent installing anything not already vetted, lockfile pinning with hash verification in CI, and a pre-install check that flags hallucinated imports before a single package is fetched.
The scale is not a vendor talking point. A peer-reviewed study at USENIX Security 2025 found that 19.7% of packages recommended by LLMs did not exist — 205,474 unique hallucinated names across the tested models (Spracklen et al.). Nearly one in five suggested dependencies is a name an attacker could claim, and the hallucinations repeat, which makes them worth squatting.
What slopsquatting is
The name is a play on typosquatting. Typosquatting waits for a human to fat-finger expres instead of express; slopsquatting does not need the typo. The model confidently recommends a plausible-sounding library — often one that *should* exist by naming convention — and because the same prompt tends to produce the same hallucination, an attacker can pre-register the name and simply wait (FOSSA; Ars Technica). The victim never made a mistake; the model did, and the install command laundered it into an action.
Why coding agents make it worse
A human reading an AI suggestion has a beat to notice an unfamiliar package name before typing install. An agent removes that beat. It generates an import, decides it needs the dependency, and runs the install in the same autonomous loop — no pause, no second look. The very autonomy that makes the agent useful is what turns a hallucinated name into an executed postinstall script. That is why the defense has to live at the install boundary, not in a developer's habits.
An install-gate for your agent
The install-gate is a PreToolUse hook that intercepts install commands and refuses any package not already pinned in your lockfile. Bare reinstalls from the lockfile pass; adding something new stops and hands it back to you to verify. Wire it on the Bash tool in settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 $CLAUDE_PROJECT_DIR/.claude/hooks/install-gate.py" }
]
}
]
}
}#!/usr/bin/env python3
"""PreToolUse install-gate. Refuses to install any package that is not already
pinned in package-lock.json, so a hallucinated name never reaches the registry.
Exit 2 blocks (reason returned to Claude); exit 0 allows."""
import json, re, sys
from pathlib import Path
payload = json.load(sys.stdin)
cmd = payload.get("tool_input", {}).get("command", "")
m = re.search(r"\b(?:npm|pnpm|yarn)\s+(?:add|install|i)\b(.*)", cmd)
if not m:
sys.exit(0) # not an install command
def base_name(spec):
# strip a trailing @version/@tag, keep an @scope/ prefix intact
if spec.startswith("@"):
scope, _, rest = spec.partition("/")
return scope + "/" + rest.split("@")[0]
return spec.split("@")[0]
pkgs = [a for a in m.group(1).split() if not a.startswith("-")]
if not pkgs:
sys.exit(0) # bare install reinstalls from the lockfile — allowed
locked = Path("package-lock.json").read_text() if Path("package-lock.json").exists() else ""
unknown = [p for p in pkgs if f'"{base_name(p)}"' not in locked]
if unknown:
sys.stderr.write(
"install-gate: not pinned in package-lock.json: " + ", ".join(unknown) +
". Verify each name is a real package (not hallucinated), then add it "
"deliberately and commit the lockfile.\n"
)
sys.exit(2)
sys.exit(0)For a stricter posture, replace the lockfile check with an explicit allowlist file the team maintains — the agent may only install from a reviewed set of names. The pip equivalent gates pip install against your pinned requirements.txt. Either way, the effect is the same: new dependencies become a human decision, which is the one step slopsquatting needs you to skip.
Pin the lockfile and verify hashes in CI
A lockfile does two jobs here: it names exactly which versions install, and — critically — it records an integrity hash for each one. Installing through the lockfile means a package whose contents do not match its recorded hash is rejected, so a name that was swapped or re-published under an attacker cannot slip in. Enforce it in CI, not just locally:
# .github/workflows/ci.yml — install only what is pinned and hash-verified
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# npm ci fails if package.json and package-lock.json disagree, and
# verifies every tarball against the integrity hash in the lockfile.
# --ignore-scripts stops install-time payloads from running in CI.
- run: npm ci --ignore-scriptsFor Python, generate a fully hashed, pinned requirements file and refuse anything without a matching hash:
# Pin every dependency with a cryptographic hash:
pip-compile --generate-hashes -o requirements.txt requirements.in
# CI install rejects any package whose download does not match its hash:
pip install --require-hashes -r requirements.txtDetect hallucinated imports before install
The earliest catch is before install happens at all: diff what the generated code imports against what your manifest already declares, and treat every newly-imported name as suspect until a human confirms it is real. A short pre-install routine:
- Parse the new or changed files for
import/requirestatements and collect the external package names. - Subtract the names already listed in
package.json/requirements.txt— those are vetted. - For each remaining name, confirm it exists and is credible: check registry age, download counts, and the repository link. A package created days ago with no history is a red flag.
- Cross-check the name against the model's own suggestion — hallucinations are often near-misses of a real package (a singular/plural flip, a hyphen, a wrong scope).
- Only after a human confirms the name is real does it enter the manifest and pass the install-gate.
npm view <pkg> time.created or the PyPI release date, weighed against a suspiciously low download count, catches a large share of them before you ever install.Where the free tooling fits
Claude Code's free /security-review and security-guidance plugin help you read generated code critically, and they are worth running — but they review what is already written; they do not gate the install itself. The controls in this guide sit earlier in the loop, at the moment a package name becomes an install command. The Kaairos Security Kit ships them as maintained, license-audited hooks and templates layered on top of that free tooling, so the install-gate and lockfile discipline stay current as npm, PyPI, and the Claude Code hook API change.
The mistake to avoid
The mistake is trusting the package name because the surrounding code looks right. A model that writes a clean, correct-looking module will name a dependency that does not exist with the same confidence, and an agent will install it without blinking. Put the check at the boundary: gate installs against a lockfile or allowlist, verify hashes in CI, and vet every new import before it lands. For the wider picture, see the AI coding agent guardrails checklist, how to review AI-generated code for security, and the evidence behind whether AI-generated code is secure.