结论先说:llms.txt 里那句"出问题就执行 npx xxx"被第三方抢先注册成恶意包,Clerk 事后把包名占回来并在 package.json 描述里写正确命令。本机 2026-11-12 抽样 21 个站点的 llms.txt,仍有 6 站放可执行指令,其中 1 条是裸包名。
背景:从"读给 AI 听的目录"变成"让 agent 执行的说明书"
llms.txt 最初的定位很安静——一份给模型读的站点目录。到了 2026 年,主流编码代理开始把它当成操作说明:文件里写"MCP 服务地址",代理就去连;写"安装命令",代理就去执行。
问题在于这份规范本身没有安全模型。llmstxt.org 至今是作者主张(proposal),2026-08 的 v2 修订补的是 <link rel="alternate" type="text/markdown">、多语言分册、以及 Agent Instructions / Skills 这类"给代理的指令片段",没有补任何签名、校验或来源约束。文件本身是纯文本,谁都能改,改完也没有哈希可对。
学术侧已经把这条路走通了。arXiv 2605.14460(Xinyu Liu、Yukai Zhao、Xing Hu、Xin Xia,《Exploiting LLM Agent Supply Chains via Payload-less Skills》)提出"语义合规劫持"(Semantic Compliance Hijacking,SCH):不注入任何代码 payload,只把恶意目标写成一段"必要合规规则"的自然语言,让代理自己去生成并执行代码。论文在 3 个主流 agent 框架 × 3 个基座模型上跑矩阵测试,最脆弱配置下机密泄露成功率 77.67%、远程代码执行(RCE)成功率 67.33%。
换成工程语言:llms.txt 里每一行可执行命令,都是一条发给代理的"建议执行"。 而建议执行的包里如果没人占名,就是攻击面。
落地清单
一、事故现场:Clerk 那条裸包名命令
2026-07-24 前后,Clerk 的 llms.txt 里有一条 Next.js 排障段,大意是"遇到 auth protection 问题,执行 npx clerk-next-fix-auth-protection"。包名是裸的——没有 scope、也没有发布者。
随后 npm 上出现 clerk-next-fix-auth-protection 的 7.7.7 / 8.8.8 版本,安装钩子在 postinstall 阶段收集主机名、用户名、当前工作目录并外发,被归为 CWE-506(内嵌恶意代码),恶意包编号 MAL-2026-11069。
说明:本机 2026-11-12 已无法从 clerk.com/llms.txt 取到这段原文(命令在 2026-09-09 前被移除),上述片段来自公开事件复盘文章的复述,不是本机直取,引用时请注意这一层。
二、修复动作:把包名"抢回来",并在描述里写正确命令
这是我认为最值得抄的一步。Clerk 没有停在"删掉那条命令",而是把包名注册回来,让任何人再造一个同名包都失败。
本机直查 registry.npmjs.org(2026-11-12)的真实结果:
| 字段 | 值 |
|---|---|
| 包名 | clerk-next-fix-auth-protection |
| 现存版本 | 1.0.0、1.0.1(共 2 个) |
| 维护者 | dominic-clerk |
| 首次发布 | 2026-08-20T19:22:14Z |
| 最近改动 | 2026-08-20T19:24:18Z |
| description | You probably meant to run npx --package @clerk/eslint-plugin clerk-next-fix-auth-protection |
| scripts | 只有 test: echo "Error: no test specified" && exit 1(无 postinstall) |
| repository | 无 |
三个细节都可学:
占名而不是删文档——删掉命令只能救自己的读者,占名能救全网所有抄过这条命令的人;
把正确命令写进 package.json 的 description——执行者
npm view一眼就能看到;包体保持空壳——没有 postinstall,没有安装钩子,被调用也不会做事。
正确调用形态(scope + 明确的包映射):
npx --package @clerk/eslint-plugin@latest clerk-next-fix-auth-protection
三、现在的 clerk.com/llms.txt 长什么样(本机直取,2026-11-12)
HTTP 200 / Content-Type: text/plain / 5,901 字节 / 102 行 / 54 条链接(30 条直指 .md),全文检索 npx、pnpm dlx、pip install 均 0 命中。节选前 18 行:
# Clerk > Clerk is user management for developers: sign-up, sign-in, and prebuilt UI components ... For AI agents and automation, use the tools and markdown surfaces below. Append `.md` to any `/docs`, `/articles`, `/blog`, `/changelog`, or `/glossary` URL to get the page as markdown. ## When to use Clerk Use Clerk when an application needs sign-up, sign-in, and user management ... ## Agent setup - [Clerk Skills](https://clerk.com/SKILL.md): One file that routes a coding agent to the right framework-specific skill - [Clerk CLI](https://clerk.com/docs/cli.md): Commands, flags, and agent workflows. Install with `npm install -g clerk` or the [install script](https://clerk.com/install) - [Clerk MCP server](https://mcp.clerk.com/mcp): Sign in once and let an agent read and configure your Clerk apps from an MCP client. - [Agent registration](https://clerk.com/auth.md): Provision an accountless Clerk application and keys without an account - [Skills discovery](https://clerk.com/.well-known/agent-skills/index.json): Every individually published skill ## SDKs - [Next.js](https://clerk.com/docs/nextjs/getting-started/quickstart.md) - [React](https://clerk.com/docs/react/getting-started/quickstart.md)
注意最后保留下来的那条命令是 npm install -g clerk——指向自己的官方包,名字与品牌一致,别人抢不走。这是"能留命令"和"不能留命令"的分界线:包名是否被自己持有。
四、可抄的自检脚本:把 llms.txt 当代码审
发布前跑一遍,把"裸包名"当 error。这段脚本只用标准库,可以直接挂 CI:
import json, re, urllib.request, urllib.parse
LLMS = "https://example.com/llms.txt" # 你自己的 llms.txt
PAT = re.compile(r'`?\b(npx|pnpm\s+dlx|yarn\s+dlx|npm\s+i(?:nstall)?|pip3?\s+install|uv\s+add)\b\s+([A-Za-z0-9@][A-Za-z0-9@._/\-]*)')
def registry(pkg, kind="npm"):
base = "https://registry.npmjs.org/" if kind == "npm" else "https://pypi.org/pypi/"
try:
with urllib.request.urlopen(base + urllib.parse.quote(pkg, safe='@'), timeout=10) as r:
j = json.loads(r.read())
except Exception as e:
return {"exists": False, "err": type(e).__name__}
if kind == "pypi":
return {"exists": True, "version": j["info"]["version"], "owner": (j["info"].get("author") or "")}
latest = j.get("dist-tags", {}).get("latest")
ver = j.get("versions", {}).get(latest, {})
return {"exists": True, "version": latest,
"maintainers": [m.get("name") for m in j.get("maintainers", [])],
"repository": (ver.get("repository") or {}).get("url"),
"scripts": ver.get("scripts") or {}}
def main():
text = urllib.request.urlopen(LLMS, timeout=15).read().decode("utf-8", "replace")
bad = 0
for m in PAT.finditer(text):
kind = "pypi" if m.group(1).startswith("pip") else "npm"
pkg = m.group(2).rstrip(".,;`)")
if kind == "npm" and not (pkg.startswith("@") or "/" in pkg):
print(f"[ERROR] 裸包名:{m.group(1)} {pkg} -> 改成 npx --package @scope/{pkg}@x.y.z")
bad += 1
info = registry(pkg, kind)
if not info["exists"]:
print(f"[ERROR] 包不存在,可能被抢注盯上:{pkg} ({info.get('err')})")
bad += 1
else:
print(f"[INFO ] {pkg} v{info.get('version')} {info.get('maintainers') or info.get('owner')} {str(info.get('repository'))[:60]}")
if kind == "npm" and set(info.get("scripts") or {}) & {"postinstall", "preinstall", "prepare"}:
print(f"[ERROR] {pkg} 含安装钩子:{info['scripts']}")
bad += 1
raise SystemExit(1 if bad else 0)
if __name__ == "__main__":
main()五、别靠"看着像"判断,要落到维护者与仓库
vercel.com/llms.txt 里有一条:`Install with npx plugins add vercel/vercel-plugin 。plugins 是个裸名,看着该报警——但 registry 显示这个包 2013 年就存在(当前 1.3.6),maintainers 是 quuu、jonathanhefner,repository 指向 github.com/vercel-labs/plugins`。
结论:不是抢注,但也不是 Vercel 组织账号持有;能追到仓库,属于可接受。判据是 maintainers + repository + scripts 三件套,不是名字里有没有品牌词。
结果:21 个真实 llms.txt 的抽样(本机 2026-11-12 直取)
24 个地址里 21 个拿到 200。体积中位数 11,808 字节、均值 166,547 字节——差 14 倍,说明分布是极端长尾(少数超大文件把均值抬起来):
| 站点 | 状态 | Content-Type | 字节 | 行数 | 链接 | .md 链接 | 可执行指令 |
|---|---|---|---|---|---|---|---|
| www.twilio.com | 200 | text/plain | 2,520,457 | 10,478 | 8,209 | 8,209 | 0 |
| posthog.com | 200 | text/plain | 501,868 | 4,999 | 4,763 | 4,757 | 2 |
| docs.stripe.com | 200 | text/markdown | 99,231 | 750 | 534 | 523 | 0 |
| stripe.com | 200 | text/plain | 69,923 | 402 | 308 | 24 | 0 |
| deno.com | 200 | text/html | 58,366 | 37 | 0 | 0 | 0 |
| drizzle.team | 200 | text/html | 56,595 | 604 | 0 | 0 | 0 |
| www.prisma.io | 200 | text/plain | 37,145 | 138 | 208 | 100 | 0 |
| bun.sh | 200 | text/plain | 33,834 | 331 | 322 | 319 | 0 |
| sanity.io | 200 | text/markdown | 30,081 | 182 | 140 | 0 | 0 |
| nextjs.org | 200 | text/plain | 14,592 | 136 | 63 | 1 | 0 |
| turso.tech | 200 | text/plain | 11,808 | 383 | 0 | 0 | 1 |
| www.mongodb.com | 200 | text/plain | 11,099 | 115 | 64 | 7 | 1 |
| resend.com | 200 | text/plain | 10,432 | 185 | 70 | 33 | 0 |
| railway.com | 200 | text/plain | 7,029 | 70 | 41 | 35 | 0 |
| mintlify.com | 200 | text/plain | 6,884 | 80 | 43 | 37 | 0 |
| hono.dev | 200 | text/plain | 5,927 | 106 | 94 | 0 | 0 |
| clerk.com | 200 | text/plain | 5,901 | 102 | 54 | 30 | 1 |
| fly.io | 200 | text/plain | 5,590 | 73 | 39 | 25 | 2 |
| vercel.com | 200 | text/plain | 5,539 | 68 | 28 | 21 | 1 |
| supabase.com | 200 | text/plain | 3,516 | 59 | 44 | 32 | 0 |
| svelte.dev | 200 | text/plain | 1,676 | 23 | 7 | 0 | 0 |
| neon.tech | 308 | — | — | — | — | — | 需跟随跳转 |
| docs.anthropic.com | 308 | — | — | — | — | — | 需跟随跳转 |
| cloudflare.com | 403 | — | — | — | — | — | 拒绝访问 |
三个可复现的观察:
6/21 站(28.6%)在 llms.txt 里写了可执行安装指令:clerk(
npm install -g clerk)、fly.io(npm install @fly/sprites、pip install sprites-py)、posthog(npx @posthog/wizard@latest、npx @posthog/wizard mcp add)、turso(pip install libsql)、vercel(npx plugins add vercel/vercel-plugin)、mongodb(只提"installation via npx",未给包名)。其中裸包名 1 条(占 4.8%),就是 vercel 那条,实测被 vercel-labs 仓库对应,非恶意。2 站(9.5%)的 llms.txt 返回 text/html:deno.com 58,366 字节、drizzle.team 56,595 字节。这是框架 fallback 把不存在的 llms.txt 兜成了页面,代理按"200 即成功"会把整页 HTML 当 Markdown 读进来。
体积极值来自链接清单暴力展开:twilio.com 一份 2.5MB、8,209 条
.md链接;posthog 50 万字节。这类文件对上下文窗口是纯负担,与"给代理省 token"的初衷相反。
外部数据(标注口径,非本机复现):Compendia Labs 在 2026-08-29 披露的扫描覆盖 6,214 个域名、8,265 个 llms.txt 文件,称有 120 份文件引用了未注册的包名或域名、合计约 227 条安装指令;Novee Security 在 Black Hat 2026 报告了 CVE-2026-54316。需要说明的是:该机构未公开商业化信息,单一行文来源,未见第三方复现;引用时请连同这层不确定性一起标注。
复盘:3 条配置动作 + 2 条踩坑
能直接抄的 3 条
指令要么删,要么带 scope 和固定版本。
npx --package @scope/name@4.0.1 cmd这种写法把"取哪个包"钉死;CI 里加--no-install阻止隐式安装。把自检脚本挂进发布流水线。裸包名 → exit 1,包不存在 → exit 1,含 postinstall → exit 1。llms.txt 从此变成需要过审的代码,而不是随手生成的知识库文件。
对自己发过的每条命令做"占名"动作。像 Clerk 那样把包名注册下来、描述里写明正确命令、包体留空壳,成本低、覆盖面最大。
2 条提醒
不要用构建脚本"自动生成"llms.txt 里的命令段。自动生成的链接没人复核,写错一个包名就是一次供应链事故,而且会在全网被抄。
不要只用 HEAD 判断 llms.txt 是否存在。SPA / 框架 fallback 会把不存在的路径兜成
200 + text/html,必须核对Content-Type与正文前几行。
标注
llms.txt 版本:v1 主线为主,部分站点已在用 v2 形态(
.md兄弟文件、type="text/markdown"内容协商、Skills 发现端点);规范状态为作者主张,未被 LLM 厂商官方确认,2026-08 的 v2 修订未引入任何安全模型。事故时间线(2026-07-24 恶意包、MAL-2026-11069、2026-09-09 前移除命令)来自公开事件复盘,非本机直取;Clerk 事后占名的一手证据是本机 2026-11-12 直查 npm registry。
抽样数据口径:2026-11-12 本机 HTTP GET 直取原始未压缩正文,字节数为响应体长度;同时段 registry.npmjs.org 直查。非第三方复现,可复跑。
本文不含排名、流量、AI 引用次数类数据,不做能力断言。
来源:clerk.com/llms.txt、registry.npmjs.org、theaifiles.app/stories/llms-txt-agent-supply-chain、arxiv.org/abs/2605.14460。