anthropics/claude-plugins-official · .github/workflows/scan-plugins.yml

scan-plugins · 让 LLM 给插件市场把关

官方插件市场最硬的差异化,不是人工审核,而是一条跑在 GitHub Actions 里的流水线:用 Claude CLI 读每个第三方插件的源码,按 policy/prompt.md 做结构化安全裁决,再把这个"不确定的 LLM 判断"包进确定性的缓存、门控、回滚与审计骨架里。本页基于 scan-plugins.yml 与 .github/policy/prompt.md 的实际内容拆解。

8
治理 workflow
30 天
verdict 缓存
0
静态密钥 (WIF)
1×
每 (plugin,sha) 只审一次

一次 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 永远会报告状态。

01
Check for scan-relevant changesgit diff base..HEAD 限定 marketplace.json + policy/;无变化则 relevant=false,整段重活跳过
↓
02
Restore verdict cachekey = scan-verdicts-{hash(policy/**)}-{run_id}-{run_attempt};restore-keys 取最近一份
↓
03
Filter scan targets against cache把变更条目切成 cached(跳过)与 to_scan(要审),构造只含 uncached 的临时 marketplace
↓
04
Scan uncached entries调 claude-plugins-community/scan-plugins action,WIF 鉴权跑 Claude CLI;continue-on-error
↓
05
Merge verdicts → gate / artifact / sticky commentcached+fresh 合并,run-verdicts.json 上传,按 passes 门控 required 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 完成:

scan-plugins.yml — Filter scan targets against cache
# 命中需要整个 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.json
  • hooks/ 下每个文件、每个 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 守卫也就不再需要。

为什么这对安全很关键:scan 让 Claude 对一个 clone 下来的外部仓库有 Read 权限——这是 prompt-injection 的高危面。进程 env 里带的是分钟级过期的 OIDC JWT(已 mask)+ 换来的短命 bearer,而非长寿命 sk-ant- key。一旦被外部 repo 的注入内容诱导外泄,泄露的也只是一个几分钟后就失效的 token,把 blast radius 压到最小。

把"读外部仓库的 LLM"当成敌意输入源

扫描结果里的 summary 和 violations 都是模型生成、且被外部 repo 内容塑形的文本——它们最终会进入公开渲染的 sink(GitHub step summary、PR sticky comment)。流水线对此做了多层净化,把 LLM 输出当作不可信数据来对待:

scan-plugins.yml — Merge verdicts(净化片段)
# ① 即使 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 仓库最硬的护城河。