# 66 · 业务插件上传的「P0 误报」与 admin 显式信任 - 日期:2026-09-12 - 触发:把 AnySearch 插件投放进候选池时被安全检测 **P0 阻断**(命中 `package/README.md` 等 4 个文档);用户提出「让 admin 在插件管理里手动标注信任」 - 结论一句话:**安全检测从「命中即 400、命中详情只进日志」改成「默认拒绝但逐条回显 + admin 显式声明信任后放行并留痕」** —— 防线不动,把判断责任交给本就可信、且能看到证据的 admin。 - 状态:✅ 已实现、已验证、已部署(2026-09-12 11:15) > **TL;DR**|**结论**:P0 规则 `/\.credentials\.yaml/` 是**纯字面量匹配、扫所有文本文件**,第三方插件的 README 只要教用户把 key 写进凭据文件就会被判「读取实例会话密钥」—— 这是第二次同类误报(档案 29 记过 `dsh-memory` 的 `docs/…example.yml`)。 > **关键**:不降低规则强度(不改规则),改为 **fail-closed + 可显式覆盖 + 全程留痕**。 > **状态**:✅ 已上线 --- ## 一、问题 ### 1.1 现象 投放 `@anysearch/anysearch-dsh@0.1.4` 时: ``` 安全检测未通过(P0 阻断):读取实例会话密钥(package/README.md) statusCode: 400 ``` 按 P0 规则全集扫描该包,命中 **4 处,全部是文档**、**代码文件 0 命中**: | 文件 | 命中内容 | |---|---| | `README.md` | "Store the key in `$DSH_HOME/.credentials.yaml`" | | `README.zh-CN.md` | 中文版同句 | | `docs/user-guide.zh-CN.md` | 凭据解析顺序说明 | | `docs/agent-installation.md` | 指导 agent 让用户配置 key | 即:**教用户正确配置凭据**被当成了**读取凭据**。 ### 1.2 根因 `src/web/security-scan.ts` 的 P0 规则 `{ re: /\.credentials\.yaml/, why: '读取实例会话密钥' }`: - **纯字面量匹配**,无上下文判断 - **扫描所有文本文件**(`SCAN_TEXT_EXT` 含 `.md`) - 命中即 `throw httpError(400, ...)`,**调用方无法裁决** - 命中详情只能通过异常消息带出**一条**,且旧文案是脱敏的一句话 —— **admin 看不到"到底哪里命中了什么"** ### 1.3 为什么不能靠收窄规则解决 两个候选方案都被否: | 方案 | 否决理由 | |---|---| | 把 P0 规则限定在代码类文件(`.md`/`.txt` 降级为 P1) | 这是**降低所有人的防线**(虽然实际防护力损失很小),且以后每遇到一类新误报都要再动一次规则 | | 手工把 tgz 放进候选池(绕过扫描) | 绕过安全机制,且**不可复现** —— admin 将来通过门户重新上传同一个包仍会被挡 | **用户提出的方案更优**:把判断责任交给 **admin**(上传本就仅 admin 可做,责任边界清楚),前提是**把证据给足**。 ## 二、方案 **三层改动,规则本身一行未动:** | 层 | 改动 | |---|---| | 扫描 | 新增 `scanDirDetailed()`:**收集 P0 命中而不抛**(`walk()` 抽出为共享遍历);**`scanDir()` 行为完全不变**(技能上传等路径仍"命中即 400") | | 上传 API | P0 命中且未声明信任 → **409** + `blocked[]`(文件 + 原因)+ `warnings[]`;带 `trust.confirmed=true` → 放行 + 写两条 audit | | 门户 | 被挡时**逐条列出命中**(文件 + 规则)+ 信任理由输入框 +「我已逐条确认,信任并投放」按钮 | **三条设计底线**(写进代码注释与 UI 文案): 1. **必须显式** —— 缺省 fail-closed,没有"默认放过"的路径; 2. **必须留痕** —— `upload_business_plugin`(含 `trustedOverride`)+ `trust_business_plugin`(含逐条命中与理由)两条审计; 3. **必须展示证据** —— 让 admin 真的看到自己信任了什么,而不是点一个盲确认。 ## 三、实现 | 文件 | 改动 | |---|---| | `src/web/security-scan.ts` | 新增 `ScanBlock` / `ScanResult`;`walk()` 抽出共享;`scanDir()` 保持抛错语义;新增 `scanDirDetailed()` | | `src/web/routes/business-plugins.ts` | `StagedPlugin` 增 `blocked`/`warnings`;`stageTgzArchive()` 改用收集模式;POST 接受 `trust` 并裁决 + 留痕 | | `web/portal.html` | 抽出 `doUpload(file, trust)`;新增 `renderScanBlocked()` 渲染命中详情 + 信任入口 | **自检**:`npm run typecheck` / `npm run build` 均 exit 0。 ## 四、验证(2026-09-12 部署后实测) | 场景 | 期望 | 实测 | |---|---|---| | 不带信任投放 AnySearch | fail-closed 拒绝 + 列 4 处命中 | ✅ 列出 `README.md` / `README.zh-CN.md` / `docs/agent-installation.md` / `docs/user-guide.zh-CN.md`,exit 2 | | 带 `--trust` 投放 | 放行 + 落库 + 留痕 | ✅ 投放成功;`business_plugins` 已有 `@anysearch/anysearch-dsh @ 0.1.4` | | 审计留痕 | 两条 | ✅ `upload_business_plugin{trustedOverride:true, via:…}` + `trust_business_plugin{blocked:[4条], reason:…}` | | 既有行为未受影响 | 技能上传仍 fail-closed | ✅ `scanDir()` 未改语义(仅内部重构) | ## 五、待办 1. **官方目录批量导入路径(`/api/plugins/whitelist/import`)未接信任入口** —— 它仍会因 P0 直接失败(结果是 `{ok:false, error}`)。走那条路的插件目前**无法信任投放**;需要同样的「命中详情 + 信任」UI(多选批量的交互要单独设计)。 2. **信任状态未持久化** —— 本轮未加 DB 列,列表页暂不显示「已信任」徽标。建议与「默认开启」的字段**合并成一次 migration v6**(避免两次迁移 + 两次重启)。 3. **`scripts/ensure-anysearch-admin.cjs` 的手工覆写段待退役** —— 其 web provider 覆写职责已由档案 65 的平台托管段接管;两段并存时「整体替换」语义会互相覆盖。