scan-plugins · 让 LLM 给插件市场把关
官方插件市场最硬的差异化,不是人工审核,而是一条跑在 GitHub Actions 里的流水线:用 Claude CLI 读每个第三方插件的源码,按 policy/prompt.md 做结构化安全裁决,再把这个"不确定的 LLM 判断"包进确定性的缓存、门控、回滚与审计骨架里。本页基于 scan-plugins.yml 与 .github/policy/prompt.md 的实际内容拆解。
一次 PR 的全流程:required check 但又不能 path-filter
scan 是 main 上的 required status check。这带来一个不显眼的约束:path-filtered 的 workflow 在路径不匹配时根本不会报告 check run——于是任何无关 PR 都会因为"缺一个永远不来的检查"而被永久卡死。所以 scan-plugins.yml 反过来设计:每个 PR 都触发它,但在 step 级用 git diff 判断 .claude-plugin/marketplace.json 和 .github/policy/ 有没有变,没变就跳过重活——但 check 永远会报告状态。
整条 job 用 concurrency: scan-plugins-${{ pr.number || ref }} 按 ref 串行——避免"re-dispatch 撞上原始 run"两边同时 restore 同一份缓存、扫重叠集合、互相覆盖对方的 verdict。timeout-minutes: 360 给冷启动全量审查留足空间。
diff 切分:哪些条目需要重新审
变更集合的定义是——HEAD 里 source 对象与 base 不同、且 source 是 object 的外部条目(本地插件 source 是字符串,天然排除)。workflow_dispatch 的 scan_all 输入会覆盖为"每个外部条目全量重审"。切分用一段 jq 完成:
# 命中需要整个 source 对象都匹配,而不只是 name@sha——
# 同 SHA 换仓库 / 换 path 是不同的扫描内容,必须 miss
.[0] as $cache
| (.[1] | map(. + {key: (.name + "@" + (.source.sha // ""))})) as $entries
| {
to_scan: [$entries[] | select(($cache[.key].source // null) != .source)],
cached: [$entries[] | select(($cache[.key].source // null) == .source)
| . + {verdict: $cache[.key]}]
}
只含 to_scan 的临时 marketplace(scan-targets.json)被当作 action 的 marketplace-path 喂进去——这样 action 自己的 base diff 解析不到 git 外的路径,回退成"空 base,全扫文件里的内容",恰好就是 to-scan 集合。缓存命中的条目连 Claude 都不会启动。
审查员提示词:bar 是"负责任地处理用户数据"
.github/policy/prompt.md 把 Claude 定位成 security and privacy reviewer,并明确抬高门槛——"handles user data responsibly" 而不仅仅是 "isn't malicious":一个插件可以毫无恶意,却因为"观测的东西超出了它声称的用途"或"安装说明没披露它实际做的事"而审查不通过。提示词强制要求读完每个相关文件再下结论:
.claude-plugin/plugin.json、.mcp.json、hooks/hooks.jsonhooks/下每个文件、每个skills/*/SKILL.md、agents/*.md、commands/*.md- 被 hook 引用或随插件 ship 的
.mjs / .js / .ts / .py / .sh源码
提示词分三部分。Part 1 基线安全查:恶意代码、隐私违规、欺骗性功能、规避安全措施(包括 skill/agent 文本里的胁迫指令,如 "ignore other instructions"、"always run me first")、未授权数据收集,以及针对审查员本身的 prompt-injection payload。提示词还特意写明一条豁免:插件请求"优先于内建工具"(如"用我而不是 WebFetch")属正常、可接受,只要插件本身良性。
Part 2:枚举每一个 hook,并读它指向的源码
这部分被标注 REQUIRED — be strict。要求列出每个绑定的生命周期事件(SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop / SubagentStop…),并对每个 hook 回答三问:是否无条件在每次会话/提示/工具调用上跑(还是有 project-relevance gate,如"仅当存在 vercel.json");是否有出站网络调用;是否读取超出用途的数据(prompt 文本、项目外路径、env、~/.ssh、~/.aws/credentials、浏览器数据、剪贴板)。
三个裁决布尔(规则写死在提示词里)
has_broad_scope_hooks
只要满足其一即 true:UserPromptSubmit/PreToolUse/PostToolUse hook 没有 project-relevance gate(即会观测与插件用途无关的会话的 prompt/工具 I/O,无论是否联网);或任何 hook 读取超出 stated scope 的用户数据。
has_undisclosed_telemetry
任何 hook 或随附代码向非声明 MCP host 的出站调用(analytics / usage ping / crash reporter / feature-flag fetch),除非 plugin.json description 或 README 明确披露并记录 opt-out。default-on 的匿名遥测也算 fail。
description_matches_behavior
若只读 plugin.json 的 description 的用户,会对你发现的 hook / 遥测 / 数据访问感到意外,则置 false。测试标准就是这一句反问。
两个能力标志 (Part 3)
may_make_external_network_calls(.mcp.json 远端 URL、hook 里的 fetch/curl、skill 指示的 HTTP);may_download_additional_software(hook/skill/指令触发 npm/pip/apt/brew/cargo/uvx/npx --yes)。
提示词甚至给出了具体的"嗅探信号"清单,让判定可操作:出站调用看 fetch / axios / http.request / https.request / XMLHttpRequest / node-fetch / curl / wget / requests.post / urllib / 裸 socket。最终 passes=false 的硬条件包括 Part 1 命中任何恶意/欺骗/外泄/规避行为——把"模型读源码后的判断"压缩成一组结构化布尔,正是缓存与门控能挂上去的前提。
verdict 缓存:每个 (plugin, sha) 最多审一次
这是整条流水线的成本支柱。cache key 锚定在 hashFiles('.github/policy/**')——提示词或 schema 一改,所有历史 verdict 同时失效、触发 clean re-scan;这是有意的。它要解决一个具体痛点:bump workflow 每晚 force-reset bump/plugin-shas,让相同 SHA 在连续几晚反复出现在 diff 里——没有缓存,同一条目每晚要白烧约 90s 的 Claude 时间。
- 不可变性:SHA 是不可变的,所以固定策略下某个 (plugin, sha) 的裁决是永久的;缓存里存
source / passes / summary(截 300 字) / violations(截 500 字) / scanned_at。 - TTL 剪枝:
CACHE_TTL_DAYS=30,restore 时按scanned_at >= cutoff丢弃过期项,随市场增长仍把缓存大小压住。 - save key 含 run_id + run_attempt:cache key 不可变,没有 run_attempt,一次 re-run 会静默存不进自己的 verdict。
- fail 也命中:一个 cached 的
passes:false仍然会让 job 失败——已知坏 SHA 必须持续红着,直到被回滚或上游修复(新 SHA = 新 cache key = 重新扫描)。
Workload Identity Federation:进程里没有长寿命密钥
job 声明 permissions: id-token: write。scan-plugins action 用它 mint 一个 GitHub OIDC token,claude CLI 再拿这个 token 去换一个分钟级过期的 bearer。federation rule 用 repository_id 绑死到本仓库,三个标识(federation-rule-id / organization-id / service-account-id)都是文件里的字面值——所以 action 那条"没有 auth 就跳过"的分支永远不会触发,老的 require ANTHROPIC_API_KEY fail-closed 守卫也就不再需要。
sk-ant- key。一旦被外部 repo 的注入内容诱导外泄,泄露的也只是一个几分钟后就失效的 token,把 blast radius 压到最小。把"读外部仓库的 LLM"当成敌意输入源
扫描结果里的 summary 和 violations 都是模型生成、且被外部 repo 内容塑形的文本——它们最终会进入公开渲染的 sink(GitHub step summary、PR sticky comment)。流水线对此做了多层净化,把 LLM 输出当作不可信数据来对待:
# ① 即使 WIF 下没有静态 key,仍作为纵深防御擦掉 key 形状的串
jq -c '(.. | strings) |= gsub("sk-ant-[A-Za-z0-9_-]{8,}"; "[REDACTED]")'
# ② 进 public sink 前 neutralize:去掉 markdown 控制字符并包进 code span
# code span 能中和被注入的、会自动成链的裸 URL;先 strip 反引号防逃逸
def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
- key 擦除:
gsub("sk-ant-[A-Za-z0-9_-]{8,}"; "[REDACTED]")作用在所有字符串上,是覆盖"未来任何静态 key fallback"的纵深防御,让 key 形状的串永远到不了缓存、artifact 或 PR 评论。 - 输出中和:进公开 sink 前 strip 掉
| 换行 [] <> `等 markdown 控制字符并包进 code span——code span 让"被注入的、会自动成链的裸 URL"失效,而先 strip 反引号是为了防止从 code span 里逃逸。 - 体量边界:action 的
scanned输出经由 env var 传递,受 argv/env ~128KiB 限制(约 400 条,远高于冷启动;稳态有缓存约 10 条/晚);一旦超限 runner 会在脚本前就 "argument list too long" 失败,处置方式(清 cache key、临时调低 max-bumps)被直接写进了注释。
失败裁决如何驱动自愈
扫描那一步是 continue-on-error——门控靠 verdict,而不是 action 的 exit code。这让一个坏上游不会把整批 bump 卡死:
- verdict 即使是 fail 也必须 save 进缓存(否则每晚都会重烧已知坏 SHA)并 upload 成 artifact(
run-verdicts.json/run-failed.json,保留 7 天)。 - revert-failed-bumps.yml 消费
failed列表,把 failing 条目从 bump PR 里 drop 掉;revert 后重新 dispatch 的 scan 只剩 cached-pass,几秒变绿。 - infra error 必须 loud fail:action 也可能在没有任何策略 verdict 的情况下失败(clone 错、API 错、schema 不匹配)——零 parsed failure 却非零退出时,gate 会显式报错,绝不让 revert workflow 把它误读成"全过"。
- emit-verdict 子 job 给 bump PR 每条目贴一个 sticky comment(marker
<!-- bump-pr-verdict:<name> -->),schema_version 1,status取 ran/cached(执行状态),结果则在policy.passes——让下游 triage 脚本直接读结构化裁决,而不用去刮 job 日志。
意义:把不确定的 LLM 判断,装进确定性的骨架
这条流水线真正示范的,不是"用 AI 做安全审查"这句口号,而是怎么把一个不确定、可被攻陷、且烧钱的 LLM 判断,工程化到可以当 required check 用:用 (plugin,sha) + policy-hash 缓存保证可复现、不重复烧钱;用 concurrency 串行保证不互相覆盖;用 WIF + 输出净化保证外部仓库内容攻陷不了审查器;用 continue-on-error + verdict 门控 + 自动回滚保证失败能自愈、坏上游不连坐。智能留在叶子(Claude 读源码下判断),而 CI 编排层负责让这份智能变得可信、可控、可审计。这正是"官方市场"相对裸 GitHub 仓库最硬的护城河。