讲解如何将npm包安全检查工具通过Claude Code skill+hook集成,解决LLM hallucinate包名并被恶意squatting的问题(~20%的AI代码存在虚假包引用)。
SlopScan 是一个小型开源 API,用来在安装 npm/PyPI 包之前检查包名是否真实存在。它要解决的问题很具体,而且正变得越来越常见:LLM 会凭空编造包名——研究显示,在 AI 生成的代码中,大约 20% 会引用根本不存在的包——攻击者已经开始抢先在真实的软件包 Registry 中注册这些被编造出来的名称,并植入恶意 payload。这种攻击叫作 slopsquatting。更棘手的是,同一个模型在多次运行中编造出的包名约有 43% 是一致的,这意味着攻击者可以系统性地猜出这些名称,提前抢注也就有利可图。
SlopScan 本身很简单:向它提供一个包名,它会根据 Registry 中的存在时长、下载量、GitHub 信号以及其他一些因素计算信任评分,然后返回 SAFE / CAUTION / SUSPICIOUS / DANGEROUS。这篇文章真正要讲的,是如何把它接入 Claude Code,让每次安装执行之前都能自动完成检查——以及我在构建这个自动检查机制时遇到的一个非常有意思的 bug。
我把它实现成了一个 skill 加一个 hook。它们并不重复,而是在解决不同的问题。
skill 是 Claude 可以阅读并据此采取行动的文档。它很适合处理“需要做 X 时,应该这样操作”这类场景——但前提是模型得记得使用它。扪心自问:你会相信一个助手能够在每次安装包之前都记得先检查吗?跨越每一个 session,永远如此,一次例外都没有?我不会,尽管我每天都在做这类工作。
hook 在本质上有所不同:它是一条真实的 shell 命令,由 Claude Code harness 在指定的生命周期事件中确定性执行,例如 PreToolUse、PostToolUse 等;而且它可以返回一个真正阻止操作的 decision。这不是“模型决定进行检查”,而是系统每次都会运行你的脚本,无一例外。只要“模型偏偏这一次忘了”会带来真实的代价,这种区别就决定了一切。
所以:skill 用于按需手动检查;hook 则是一张安全网,不依赖任何人记得做任何事。
没什么花哨的,就是一个 SKILL.md,描述了 SlopScan 的 API contract,以及如何解读结果:
---
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.
单独使用它就已经很有价值——当 Claude 准备安装某个自己不确定的包时,就会使用这个 skill。但“不确定”本身是一种主观判断,而恰恰是在需要判断的地方,最容易出现“偏偏这一次忘了”。
下面是一个作用于 Bash tool 的 PreToolUse hook。它必须完成三件事:识别多个 package manager 的安装命令;提取真正的包名,而且只能提取包名——所有 flag、版本约束、本地路径和 URL 都必须过滤掉;最后,把 SlopScan 的 verdict 转换成真正的 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")
然后把它接入 settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 /path/to/slopscan_preinstall.py", "timeout": 20 }
]
}
]
}
}
不同 verdict 会映射到真正的 decision:如果结果是 DANGEROUS,或者某个包压根不存在于 Registry 中,就返回 deny,安装命令根本不会执行;如果是 SUSPICIOUS,就返回 ask,显示正常的 permission prompt,由你决定;其他任何结果——包括 SlopScan 无法访问——都会静默 fail open。最后一点很重要:如果一个安全检查会在网络偶尔抖动时让整个工作流瘫痪,那它不出一周就会被人关掉。基础设施出问题时 fail open,真正得到危险 verdict 时 fail closed。
下面这部分才真正值回票价。我完成了这个 hook,用 pip install 请求测试过,亲眼看着它正确放行,又正确拒绝了一个假包名,于是便宣布完工。
几周后,我运行 pip install "qrcode[pil]" cairosvg 2>&1 | tail -10——这是一种再普通不过的 shell 写法:把 stderr 重定向到 stdout,再通过管道传给 tail——结果 hook 拒绝了整个安装。错误信息是:BLOCKED — dangerous/nonexistent package(s): 2。包名“2”?qrcode 和 cairosvg 可都不叫“2”。
我的第一反应是 SlopScan 服务出现了瞬时故障——我以前见过这一类 bug:Registry timeout 得到的评分与已确认的 404 完全相同。这件事本身也值得吸取教训:绝不能把“我无法完成检查”与“我已经检查过,而且确认有问题”视为同一种情况。两者的置信度完全不同,理应采用不同的处理方式。不过,当我手动直接查询 SlopScan 中的 qrcode 和 cairosvg 时,两次都得到了干净、字段完整的 SAFE 结果。服务没有问题。把完全相同的 JSON payload 直接通过管道传给 hook 脚本,也能正常工作。只有真实、在线的 Bash-tool 调用会失败——这意味着,实际命令与我的手动重放之间存在某种差异。
我在 hook 中临时加入了 debug logging,把原始命令、提取出的 target 和 SlopScan 结果写入一个临时文件,然后通过真实场景再次触发它。日志立刻把问题暴露得一清二楚:
TARGETS: [{'ecosystem': 'pypi', 'name': 'qrcode'}, {'ecosystem': 'pypi', 'name': 'cairosvg'}, {'ecosystem': 'pypi', 'name': '2'}]
出现了第三个幽灵 target:“2”。它是这样产生的:我的 segment splitter 会按照 | 拆分串联命令,以便分别捕获 pipeline 中的每一条命令。因此,... 2>&1 | tail -10 经过 shlex.split 后,会把 2>&1 单独留作一个 token。shlex 完全不理解 shell 重定向语法;它只是一个 tokenizer,不是 shell parser,所以 2>&1 只会作为普通字符串传递下去。接着,我用于移除版本约束的正则表达式会按照 ==、>=、<=、>、<、[ 进行拆分,从包名中剥离版本约束。它看到了 2>&1 中的 >,便欣然把它当成版本分隔符,只留下了前面的内容:“2”。
这是一个完全确定性的 bug,而不是偶发故障——只要通过 2>&1 | anything 对任何安装命令进行管道处理,就一定会触发它;而对于需要捕获安装输出的人来说,这又是一种极其常见的模式。
修复只需要一行:在任何疑似 shell 重定向的内容进入版本剥离逻辑之前,就把它过滤掉。
if re.match(r"^&?\d*(>>?|<)", tok):
continue # shell redirection (e.g. "2>&1", "2>/dev/null", ">out.log") -- not a package name
这个 pattern 可以匹配 2>&1、2>/dev/null、>out.log、1>&2、&>file——也就是任何以可选的 &、可选的数字和一个重定向操作符开头的内容。我还确认了它不会误判恰好以数字开头的真实包名:2to3-pkg 可以正常通过,因为数字 2 后面的内容无法匹配重定向操作符。
我曾用干净的手写示例和几个刻意构造的对抗性示例测试这个 hook,比如虚假的包名。但我没有测试真实的命令形态——也就是 shell 在现实中真正会执行的那些乱糟糟的东西,里面混杂着管道和重定向。shlex.split 是 tokenizer,不是 shell;任何位于它下游、并假定自己接收到的都是干净参数 token 的逻辑,迟早会遇到它无法处理的重定向、subshell 或 here-doc。
如果你正在构建一个解析 shell 命令的 hook——不只是本文这个,任何在决定该怎么做之前检查 tool_input.command 的 hook 都算——真正实用的经验是:用人们实际会输入的命令形态测试它,包括对输出进行管道处理的完整写法,而不只是干净的教科书示例。当一次检查以某种无法通过手动操作复现的方式失败时,不要相信你的手动复现。先把 hook 实际收到的完整 JSON 通过管道传给它,或者加入一次性的 debug logging,然后再判断是不是另一端的服务出了问题。
SlopScan 已经发布在 GitHub 上,采用 Apache 2.0 许可证,大约两条命令就能完成 self-host。如果你已经在使用 Claude Code,那么上面的 skill + hook 组合确实属于那种“接好一次,以后再也不用操心”的增强功能——如果你曾遇到 Agent 建议安装一个自己无法完全确认的东西,那它绝对值得你花五分钟配置。
对于后续操作,你可以考虑屏蔽此人和/或举报滥用行为。