SlopScan is a small open-source API that checks whether an npm/PyPI package name is real before you install it. The problem it solves is specific and increasingly common: LLMs hallucinate package names — studies put it around 20% of AI-generated code referencing packages that don't exist — and attackers have started pre-registering those exact hallucinated names on the real registries with malicious payloads. It's called slopsquatting, and the nasty part is that ~43% of hallucinated names are consistent across runs of the same model, which makes them systematically guessable and worth squatting on.
SlopScan itself is straightforward: point it at a package name, it scores trust based on registry age, download counts, GitHub signal, and a few other factors, and hands back SAFE / CAUTION / SUSPICIOUS / DANGEROUS. What this post is actually about is wiring it into Claude Code so it checks automatically, before any install runs — and a genuinely interesting bug I hit building the automatic part.
Two pieces, doing different jobs
I built this as a skill plus a hook, and they're not redundant — they solve different problems.
A skill is documentation Claude reads and can act on. It's great for "here's how to do X when you need to" — but it only fires if the model remembers to reach for it. Ask yourself honestly: would you trust an assistant to always remember to check a package before installing it, across every session, forever, with no exceptions? I wouldn't, and I do this daily.
A hook is different in kind: it's a real shell command the Claude Code harness runs deterministically at a named lifecycle event (PreToolUse, PostToolUse, etc.), and it can return a decision that actually blocks the action. Not "the model decided to check" — the system runs your script, every time, no exceptions. For anything where "the model forgot, just this once" has a real cost, that distinction is the whole ballgame.
So: skill for on-demand manual checks, hook for the safety net that doesn't depend on anyone remembering anything.
The skill
Nothing fancy — a SKILL.md describing SlopScan's API contract and how to interpret results:
---
name: slopscan-check
description: "Check an npm or PyPI package name against SlopScan before installing it. Use before npm install/pip install/uv add, or adding any unfamiliar or LLM-suggested dependency."
---
SlopScan runs locally (or wherever you've deployed it) on port 8765.
## Single package
\`\`\`bash
curl -s http://localhost:8765/check/npm/<package-name>
curl -s http://localhost:8765/check/pypi/<package-name>
\`\`\`
## Batch (max 20 per call)
\`\`\`bash
curl -s -X POST http://localhost:8765/check/batch \
-H "Content-Type: application/json" \
-d '{"packages":[{"ecosystem":"npm","name":"<pkg1>"},{"ecosystem":"pypi","name":"<pkg2>"}]}'
\`\`\`
## Interpreting results
Each result has \`risk\` (SAFE/CAUTION/SUSPICIOUS/DANGEROUS), \`trust_score\`, \`found\`
(does the registry have it at all), and \`flags\` explaining why.
- **SAFE/CAUTION** — proceed normally.
- **SUSPICIOUS** — pause, tell the user what was flagged, before installing.
- **DANGEROUS** or \`found: false\` — do not install. A nonexistent package is the single strongest hallucination signal there is. Explain the flag, don't silently retry with a different name.
That's genuinely useful on its own — Claude will reach for it when it's about to install something it's unsure of. But "unsure of" is a judgment call, and judgment calls are exactly where "forgot, just this once" creeps in.
The hook
This is a PreToolUse hook on the Bash tool. It has to do three things: recognize an install command across several package managers, pull out real package names (and only real package names — flags, version pins, local paths, and URLs all need to be filtered out), and turn SlopScan's verdict into an actual permission decision.
#!/usr/bin/env python3
"""PreToolUse/Bash hook: check npm/pip/uv package installs against SlopScan before they run."""
import json, re, shlex, subprocess, sys, urllib.request
SLOPSCAN_URL = "http://localhost:8765" # override for a remote instance
INSTALL_PATTERNS = [
(re.compile(r"^npm\s+(?:install|i|add)\b"), "npm"),
(re.compile(r"^pnpm\s+(?:install|i|add)\b"), "npm"),
(re.compile(r"^yarn\s+add\b"), "npm"),
(re.compile(r"^pip3?\s+install\b"), "pypi"),
(re.compile(r"^python3?\s+-m\s+pip\s+install\b"), "pypi"),
(re.compile(r"^uv\s+add\b"), "pypi"),
(re.compile(r"^uv\s+pip\s+install\b"), "pypi"),
]
FLAG_VALUE_TAKING = {
"-r", "--requirement", "--index-url", "-i", "--extra-index-url", "--target", "-t", "--prefix", "--find-links", "-f",
}
def split_segments(command: str) -> list[str]:
parts = re.split(r"&&|;|\|\|?|\n", command)
return [p.strip() for p in parts if p.strip()]
def strip_version(pkg: str, ecosystem: str) -> str:
if ecosystem == "npm":
if pkg.startswith("@"):
rest = pkg[1:]
return "@" + (rest.split("@", 1)[0] if "@" in rest else rest)
return pkg.split("@", 1)[0]
return re.split(r"(==|>=|<=|~=|!=|>|<|\[)", pkg, 1)[0]
def extract_packages(segment: str, ecosystem: str) -> list[str]:
try:
tokens = shlex.split(segment)
except ValueError:
return []
idx = 0
for i, tok in enumerate(tokens):
if tok in ("install", "i", "add"):
idx = i + 1
break
tokens = tokens[idx:]
packages, skip_next = [], False
for tok in tokens:
if skip_next:
skip_next = False
continue
if tok.startswith("-"):
if tok in FLAG_VALUE_TAKING:
skip_next = True
continue
if re.match(r"^&?\d*(>>?|<)", tok):
continue # shell redirection -- see the bug story below
if tok.startswith(".") or tok.startswith("/") or "://" in tok or tok.startswith("git+"):
continue
if tok.endswith((".txt", ".whl", ".tar.gz", ".cfg", ".toml")):
continue
packages.append(strip_version(tok, ecosystem))
return packages
def collect_targets(command: str) -> list[dict]:
targets = []
for segment in split_segments(command):
for pattern, ecosystem in INSTALL_PATTERNS:
if pattern.match(segment):
targets += [{"ecosystem": ecosystem, "name": p} for p in extract_packages(segment, ecosystem)]
break
return targets
def query_slopscan(packages: list[dict]) -> list[dict] | None:
if not packages:
return None
body = json.dumps({"packages": packages[:20]}).encode()
req = urllib.request.Request(
f"{SLOPSCAN_URL}/check/batch", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return json.load(resp)["results"]
except Exception:
return None # SlopScan unreachable -- fail open, don't block on network issues
def allow():
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
def ask(reason: str):
print(json.dumps({"systemMessage": reason, "hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": reason}}))
def deny(reason: str):
print(json.dumps({"systemMessage": reason, "hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}))
def main():
try:
data = json.load(sys.stdin)
except Exception:
return
command = data.get("tool_input", {}).get("command", "")
targets = collect_targets(command)
if not targets:
return
results = query_slopscan(targets)
if results is None:
return
dangerous = [r for r in results if r.get("risk") == "DANGEROUS" or r.get("found") is False]
suspicious = [r for r in results if r.get("risk") == "SUSPICIOUS"]
if dangerous:
names = ", ".join(r.get("package", "?") for r in dangerous)
flags = "; ".join(f for r in dangerous for f in r.get("flags", []))
deny(f"[slopscan] BLOCKED -- dangerous/nonexistent package(s): {names}. {flags}")
elif suspicious:
names = ", ".join(r.get("package", "?") for r in suspicious)
flags = "; ".join(f for r in suspicious for f in r.get("flags", []))
ask(f"[slopscan] Suspicious package(s) flagged: {names}. {flags}")
else:
allow()
if __name__ == "__main__":
main()
Wired into settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 /path/to/slopscan_preinstall.py", "timeout": 20 }
]
}
]
}
}
Verdicts map to real decisions: DANGEROUS or a package that flat-out doesn't exist in the registry → deny, the install never runs. SUSPICIOUS → ask, normal permission prompt, you decide. Anything else — or SlopScan being unreachable — fails open silently. That last part matters: a security check that takes down your workflow every time your network hiccups gets disabled within a week. Fail open on infrastructure problems, fail closed on actual verdicts.
The bug: a shell redirect became a fake package name
Here's the part worth the price of admission. I built this, tested it against pip install requests, watched it correctly allow and correctly deny a fake package name, called it done.
Weeks later, running pip install "qrcode[pil]" cairosvg 2>&1 | tail -10 — a completely ordinary shell idiom, redirect stderr to stdout, pipe to tail — the hook denied the whole install. The error: BLOCKED — dangerous/nonexistent package(s): 2. Package "2"? Neither qrcode nor cairosvg is named "2."
First instinct was that SlopScan's service had a transient blip — I'd seen that class of bug before (a registry timeout getting scored identically to a confirmed 404, which is its own lesson: never treat "I couldn't check" the same as "I checked and it's bad" — those are completely different confidence levels and deserve different handling). But querying SlopScan directly, by hand, for qrcode and cairosvg came back clean, fully-populated SAFE results both times. The service was fine. Piping the exact same JSON payload directly into the hook script also worked fine. Only the real, live Bash-tool invocation failed — which meant something about how the actual command differed from my manual replay.
I added temporary debug logging inside the hook (writing the raw command, the extracted targets, and the SlopScan results to a scratch file) and re-triggered it for real. The log made it obvious immediately:
TARGETS: [{'ecosystem': 'pypi', 'name': 'qrcode'}, {'ecosystem': 'pypi', 'name': 'cairosvg'}, {'ecosystem': 'pypi', 'name': '2'}]
A third, phantom target: "2". Here's the chain that produced it. My segment-splitter splits chained commands on | (to catch each command in a pipeline separately) — so ... 2>&1 | tail -10 left 2>&1 sitting there as its own token after shlex.split. shlex doesn't understand shell redirection syntax at all; it's a tokenizer, not a shell parser, so 2>&1 just comes through as a plain string. Then my version-stripping regex — which splits on ==, >=, <=, >, <, [ to peel a version pin off a package name — saw the > in 2>&1 and happily treated it as a version delimiter, keeping only what came before it: "2".
A completely deterministic bug, not a flake — any install piped through 2>&1 | anything would hit it, which is an extremely common pattern for anyone capturing install output.
The fix is one line: filter out anything that looks like shell redirection before it ever reaches the version-stripping logic.
if re.match(r"^&?\d*(>>?|<)", tok):
continue # shell redirection (e.g. "2>&1", "2>/dev/null", ">out.log") -- not a package name
That pattern catches 2>&1, 2>/dev/null, >out.log, 1>&2, &>file — anything starting with an optional &, optional digits, then a redirect operator. I checked it doesn't false-positive on a real package name that happens to start with a digit (2to3-pkg passes through fine, since nothing after the 2 matches a redirect operator).
The actual lesson
I'd tested this hook against clean, hand-typed examples and a couple of adversarial ones (fake package names). What I hadn't tested was realistic command shapes — the messy things real shells actually run, with pipes and redirects mixed in. shlex.split is a tokenizer, not a shell; anything downstream of it that assumes clean argument tokens will eventually meet a redirect, a subshell, or a here-doc it wasn't built for.
If you're building a hook that parses shell commands (not just this one — anything that inspects tool_input.command before deciding what to do), the practical takeaway is: test it against the command shapes people actually type, piped output and all, not just the clean textbook version. And when a check fails in a way that doesn't match your manual reproduction, don't trust your manual reproduction — pipe the exact JSON the hook receives, or add throwaway debug logging, before you conclude the service on the other end is at fault.
Try it
SlopScan's on GitHub, Apache 2.0, self-hostable in about two commands. If you're already using Claude Code, the skill + hook combo above is a genuine "wire it up once, never think about it again" addition — worth five minutes if you've ever had an agent suggest installing something you didn't fully recognize.
— Cor, Skyblue Soft
Top comments (0)