cd /news/ai-safety/wiring-slopscan-into-claude-code-a-s… · home topics ai-safety article
[ARTICLE · art-83751] src=dev.to ↗ pub= topic=ai-safety verified=true sentiment=· neutral

Wiring SlopScan into Claude Code — A Skill, a Hook, and a Bug I Almost Shipped

An engineer has built SlopScan, an open-source API that detects 'slopsquatting'—malicious packages pre-registered under names hallucinated by LLMs—and integrated it into Claude Code via a skill and a PreToolUse hook. The hook automatically blocks installs of suspicious or nonexistent packages, addressing the ~20% hallucination rate in AI-generated code and the ~43% consistency of hallucinated names across model runs. The integration also surfaced a notable bug in the hook's command parsing logic.

read9 min views2 publishedAug 2, 2026

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

── more in #ai-safety 4 stories · sorted by recency
── more on @slopscan 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/wiring-slopscan-into…] indexed:0 read:9min 2026-08-02 ·