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.
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.
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** — , 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.
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.
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).
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.
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