Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-beans-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bridgent/cli": patch
---

Harden the CLI test gate so an empty test suite fails instead of reporting a successful run.
171 changes: 171 additions & 0 deletions docs/testing/v0.4-policy-acceptance-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# v0.4 Policy Runtime 验收测试矩阵

状态:Stage 1 测试契约;已于 2026-08-17 按 ADR-033 完成核心开发可执行性复核。

本矩阵把 `docs/roadmap.md` 的 v0.4 scope 和 acceptance criteria 转换为可执行的自动化测试,并以 `docs/decisions.md` 的 ADR-033 为唯一实现语义。Stage 2 实现必须保留这些测试 ID;若后续 ADR 调整 public API,只可更新调用形式和精确错误码,不得删除对应行为断言。

## 质量门禁

- P0:安全边界或兼容性红线。任何失败均为 release blocker。
- P1:已承诺的 policy/CLI 行为。Stage 4 前必须自动化并全绿。
- P2:补充覆盖。失败必须有独立 Issue、owner 和风险说明。
- 禁止 `skip`、`only`、flaky retry、弱化断言和 `passWithNoTests`。
- 测试只使用本地 mock/fixture;不得访问外网、读取真实 secret 或依赖 wall-clock 等待。
- 时间相关行为使用 fake timers;HTTP 使用注入的 mock `fetch`;Prisma 使用内存 mock client;tRPC 通过本地 caller 执行。

## ADR-033 冻结契约

- Public API 固定为 `withPolicy<TTool extends BridgentTool<any, any>>(tools: readonly TTool[], policy: BridgentPolicy): TTool[]`。它是显式 opt-in,只替换 `run`,保留 tool 的 name、description、input schema、metadata、输入/输出泛型和输入顺序;未包装的 `BridgentTool[]` 行为不变。
- 包装期复制并规范化 config、拒绝重复 tool name、校验未知 key/enum/类型和所有数值,并按每个 tool 编译 immutable decision。非法 config 抛 `BridgentPolicySetupError`,code 为 `BRIDGENT_POLICY_INVALID`;`maxTools` 统计传入的原始 tool 总数(包括之后会被拒绝的 tool),超限抛 `BRIDGENT_POLICY_MAX_TOOLS`,消息固定为 `Policy maxTools exceeded: received <actual> tools, maximum <max>.`。
- 注册期不筛选、不重命名 tool,也不执行原始 `run`;被拒 tool 仍出现在 `tools/list`。调用期先读取已编译 decision:拒绝时抛 `BridgentPolicyError`,原始 `run=0`;允许时原样调用 `run`,不改 input/result。
- tool/source selector 都是完整值、区分大小写且不支持 glob。配置 `allow` 后必须命中,空 `allow: []` 表示全部拒绝;tool 与 source allowlist 取交集,任一 denylist 命中即拒绝,deny 与 read-only/safety/limit 永远高于 allow。
- 单 tool violation 只返回固定优先级中的第一个失败原因:metadata 完整性/值域 → tool deny → source deny → tool allow → source allow → read-only → audit → preview token → rows → output bytes。测试不得要求一次列出全部未满足规则。
- 纯 tool-name policy 不读取 metadata;只有启用 source/read-only/write-safety/limit 规则时才读取对应字段。所需字段缺失或值域非法分别以 `METADATA_MISSING` / `METADATA_INVALID` fail closed;不得从 name、description 或 source reference 推断 metadata。
- `auditForWrites` / `previewTokenForWrites` 只作用于 `capability=write`。capability 缺失或非法先触发 metadata violation;capability 有效但 safety flag 为 `false` 或缺失时分别触发 `AUDIT_REQUIRED` / `PREVIEW_TOKEN_REQUIRED`。`capability=read` 携带 write safety flag 不构成冲突,flag 对 read tool 不适用。
- `rowLimit` 是 adapter 已执行的最大行数;`outputLimit` 是按最终 UTF-8 tool text 计算、adapter 已执行的最大 byte 数。policy 只比较 hint,不 clamp input、不截断 output。hint 缺失默认 `onMissing: 'deny'`;只有显式 `onMissing: 'ignore'` 才视为不适用。
- `registerTools` 只把 `BridgentPolicyError` 映射为 `isError: true`;其它既有异常继续交给 MCP SDK。allowed string 原样输出,非 string 只 `JSON.stringify` 一次。

Policy rejection 的 text content 必须是单行 JSON,稳定 machine fields 为 `kind`、`code`、`violation`、`tool`:

```json
{"ok":false,"error":{"kind":"policy","code":"BRIDGENT_POLICY_DENIED","violation":"READ_ONLY","tool":"prisma_user_delete","message":"Policy denied tool \"prisma_user_delete\": readOnly requires metadata.capability \"read\"."}}
```

对应 MCP result 固定为 `{ content: [{ type: 'text', text: JSON.stringify(payload) }], isError: true }`。message 必须以 `Policy denied tool "<name>": ` 开头,并同时描述期望值与观察值。

## Fixture 契约

共享 baseline fixture 已在 `packages/core/test/fixtures/policy-tools.ts` 实现;Stage 2 在同一文件补齐依赖最终 ADR 的 invalid/conflict fixture。adapter 集成测试继续复用各 package 现有 fixture。所有 `run`、`fetch`、audit sink 和 Prisma delegate 都必须是可检查调用次数及参数的 spy。

| Fixture ID | 形态 | 必要字段 | 用途 |
| --- | --- | --- | --- |
| `FX-READ-ZOD` | 手写只读 tool | `source.kind=zod`、`capability=read`、`rowLimit=25`、`outputLimit=4096` | allow、read-only、limit、result stringification |
| `FX-WRITE-OPENAPI` | OpenAPI `POST /things` | `capability=write`、`hasAudit=false`、`hasPreviewToken=false`、mock `fetch` | mutating reject、unsafe metadata、adapter 回归 |
| `FX-WRITE-PRISMA-SAFE` | Prisma `User.create` | `capability=write`、`hasAudit=true`、`hasPreviewToken=true`、mock delegate/audit | safety requirements、allowed commit |
| `FX-WRITE-TRPC` | allowlisted tRPC mutation | `capability=write`、`requiresAuthOrContext=true`、本地 caller | source/name rules、adapter 回归 |
| `FX-NO-METADATA` | 手写 tool | 不提供 `metadata` | metadata-dependent policy fail-closed、纯 tool-name policy 与无 policy 兼容 |
| `FX-INVALID-METADATA` | runtime-invalid tool | 通过 `unknown` 边界注入未知 `source.kind`、非布尔 safety 或非数值 limit | 防御式 fail-closed,不依赖 TypeScript 绕过运行时校验 |
| `FX-CONFLICT-METADATA` | 部分满足 safety 的 metadata | `capability=write`,且 `hasAudit` / `hasPreviewToken` 只满足一项 | 按固定优先级返回首个未满足规则,不得部分放行 |
| `FX-NO-POLICY` | 现有 adapter 与 transport 输入 | 原始 `BridgentTool[]`,不调用 policy API | v0.3 行为 baseline |

## Roadmap acceptance criteria 追踪

| AC ID | Roadmap acceptance criterion | 自动化测试 ID | 层级 | 优先级 |
| --- | --- | --- | --- | --- |
| `AC-01` | Policy 在 mutating tool 的 `run` 前拒绝调用 | `POL-U-003`、`POL-I-001`、`POL-I-002` | core unit + integration | P0 |
| `AC-02` | Server 可限制为明确 tool allowlist | `POL-U-001`、`POL-U-002`、`POL-U-010` | core unit | P0 |
| `AC-03` | 缺失必需 mutating safety metadata 时 fail closed | `POL-U-005`、`POL-U-006`、`POL-U-007` | core unit | P0 |
| `AC-04` | 无 policy 时现有 adapter 行为不变 | `POL-C-001` 至 `POL-C-006` | compatibility | P0 |
| `AC-05` | focused tests 覆盖 allowed、rejected、missing metadata、no-policy | `POL-U-001`、`POL-U-003`、`POL-U-005`、`POL-C-001` | core unit + compatibility | P0 |
| `AC-06` | `pnpm turbo run build test typecheck lint` 全绿 | `POL-G-001`、`POL-G-002` | monorepo gate | P0 |

## Core policy unit tests

建议落点:`packages/core/test/policy.test.ts`。每个拒绝测试都同时断言 `BridgentPolicyError.code === 'BRIDGENT_POLICY_DENIED'`、精确 violation code、以固定前缀开头且包含期望值/观察值的 message,以及原始 `run` 调用次数为 `0`。

| Test ID | 场景与输入 | 必要断言 | 优先级 |
| --- | --- | --- | --- |
| `POL-U-001` | tool name 在显式 allowlist 内 | 返回 wrapped tool;调用一次;input/result 原样透传 | P0 |
| `POL-U-002` | tool name 不在 allowlist | violation=`TOOL_NOT_ALLOWED`;错误包含 tool name、期望 allowlist 与观察值;`run=0` | P0 |
| `POL-U-003` | read-only policy 调用 `FX-WRITE-OPENAPI` | violation=`READ_ONLY`;在 `run` 前拒绝;`run=0` | P0 |
| `POL-U-004` | read-only policy 调用 `FX-READ-ZOD` | 允许;`run=1`;结果不变 | P0 |
| `POL-U-005` | 要求 audit 时 write tool 的 capability 缺失,或 `hasAudit` 为 false/缺失 | capability 缺失先返回 `METADATA_MISSING`;capability 有效但 safety 不满足返回 `AUDIT_REQUIRED`;`run=0` | P0 |
| `POL-U-006` | 要求 preview token 时 `hasPreviewToken=false` 或缺失 | violation=`PREVIEW_TOKEN_REQUIRED`;指出期望 true 与观察值;`run=0` | P0 |
| `POL-U-007` | 同时要求 audit + preview,fixture 只满足一项 | 不得部分放行;只返回固定优先级中的首个 violation(audit 先于 preview);`run=0` | P0 |
| `POL-U-008` | metadata-dependent policy 与纯 tool-name policy 分别遇到 `FX-NO-METADATA` | 前者以 `METADATA_MISSING` fail closed;后者仍可按 tool name 求值;不得默认为 read | P0 |
| `POL-U-009` | metadata-dependent policy 遇到 `FX-INVALID-METADATA` | 调用期抛受控 `BridgentPolicyError`,violation=`METADATA_INVALID`;`run=0` | P0 |
| `POL-U-010` | 同一 tool 同时命中 allow 与 deny | violation=`TOOL_DENIED`;deny 高于 allow且不依赖配置顺序;`run=0` | P0 |
| `POL-U-011` | source kind allow/deny 命中与未命中 | 对 `openapi`、`prisma`、`trpc` 分别断言 `SOURCE_KIND_DENIED` / `SOURCE_KIND_NOT_ALLOWED`;错误带观察到的 source kind | P1 |
| `POL-U-012` | tool name/source kind 组合规则 | 两个 allowlist 取交集、任一 denylist 生效;第一失败原因严格按 ADR-033 顺序,交换对象 key 顺序结果相同 | P1 |
| `POL-U-013` | tool 数超过 `maxTools` | 包装期原子抛 `BRIDGENT_POLICY_MAX_TOOLS` 与固定消息;计数包含之后会被 selector 拒绝的 tool;任何 tool 均不可执行 | P0 |
| `POL-U-014` | tool 数等于或小于 `maxTools` | 边界值允许,保留输入顺序和 name/description/inputSchema/metadata/泛型 | P1 |
| `POL-U-015` | row/output hint 超过 policy limit | 分别返回 `ROW_LIMIT_EXCEEDED` / `OUTPUT_LIMIT_EXCEEDED`;指出 hint 值和 policy max;`run=0` | P1 |
| `POL-U-016` | 启用 limit rule 但对应 hint 缺失/非法 | 缺失默认 `METADATA_MISSING`,非法为 `METADATA_INVALID`;`run=0` | P0 |
| `POL-U-017` | limit rule 未启用或显式 `onMissing: 'ignore'` | 未启用时不读取对应 hint;显式 ignore 时缺失 hint 视为不适用,其他规则仍正常执行 | P1 |
| `POL-U-018` | allowed call 返回 object/string | wrapper 直接调用时 object/string 均原样返回;MCP stringification 由 `POL-I-003` 断言 | P0 |
| `POL-U-019` | policy reject handler 自身被重复调用 | 每次结果确定、无全局状态泄漏、原始 `run=0` | P1 |
| `POL-U-020` | 空 tools、重复 tool name | 空 tools 合法并返回空数组;重复 name 在包装期抛 `BRIDGENT_POLICY_INVALID`,不产生半包装结果 | P0 |
| `POL-U-021` | 包装后修改原始 tools 数组、selector 数组或 policy 对象 | 已编译 decision 与 inspect summary 不变,证明 config 已复制/规范化 | P1 |
| `POL-U-022` | unknown key/enum、错误类型、`NaN`、负数、小数或非-safe-integer 上限 | 包装期抛 `BRIDGENT_POLICY_INVALID`;有定位信息时断言 `path`,不得静默忽略 typo | P0 |
| `POL-U-023` | `outputLimit` 边界与多字节 UTF-8 text | 按最终 UTF-8 byte 数语义比较已声明 hint;等于 max 允许,超过 max 拒绝;不截断结果 | P1 |

## Integration 与 transport tests

建议落点:`packages/core/test/policy-registration.test.ts`,使用 fake `McpServer.registerTool` 获取实际 handler;HTTP/Web/stdio 只保留一个代表性协议级回归,避免重复测试同一 wrapper 实现。

| Test ID | 场景 | 必要断言 | 优先级 |
| --- | --- | --- | --- |
| `POL-I-001` | MCP handler 调用被 read-only 拒绝的写 tool | 精确断言单行 JSON payload、稳定 machine fields、`isError: true` 与 message 前缀;server 不崩溃;原始 `run=0` | P0 |
| `POL-I-002` | MCP handler 调用 allowlist 外 tool | violation=`TOOL_NOT_ALLOWED`;同连接后续 allowed tool 仍可调用;拒绝项 `run=0` | P0 |
| `POL-I-003` | MCP handler 调用 allowed object-result tool | `content[0].type=text` 且 `text=JSON.stringify(result)` | P0 |
| `POL-I-004` | 多个 tools 包装并注册 | 所有 allowed/denied tools 均注册且顺序不变;name、description、input schema、metadata 保留;summary 为 server-level `policy.enforced: true` 加规范化 declarative rules | P1 |
| `POL-I-005` | transport 未配置 policy | 现有注册数量、handler 返回值和错误传播不变 | P0 |
| `POL-I-006` | 原始 `run` 抛出非 `BridgentPolicyError` | `registerTools` 不改写为 policy payload,继续沿用 MCP SDK 既有异常路径 | P1 |

## Adapter compatibility tests

这些测试不要求 adapter 改写 policy;adapter 只继续产生现有 `BridgentTool[]` 和 metadata。所有网络/数据库调用均为本地 mock。

| Test ID | 建议落点 | 场景与必要断言 | 优先级 |
| --- | --- | --- | --- |
| `POL-C-001` | `packages/core/test/server.test.ts` | 手写 tool 不配置 policy:对象 identity、调用次数和结果与 v0.3 baseline 一致 | P0 |
| `POL-C-002` | `packages/source-openapi/test/policy-compat.test.ts` | 本地 `POST` tool 无 policy 时仍按既有 allow opt-in 执行;包 policy 后 read-only 拒绝且 mock `fetch=0` | P0 |
| `POL-C-003` | `packages/source-prisma/test/policy-compat.test.ts` | safe write 同时满足 audit/preview requirement;unsafe/missing safety 在 delegate 前拒绝 | P0 |
| `POL-C-004` | `packages/source-trpc/test/policy-compat.test.ts` | query 允许;allowlisted mutation 可被 read-only/source rule 拒绝且 resolver 调用次数为 0 | P0 |
| `POL-C-005` | 现有 adapter test suites | 不配置 policy 时 OpenAPI、Prisma、tRPC 现有测试全部不改断言通过 | P0 |
| `POL-C-006` | `packages/host-test/test/cli-smoke.test.ts` | 现有 server file 不配置 policy,stdio list/call tools 行为不变 | P0 |

## CLI diagnostics tests

建议落点:`packages/cli/test/inspect.test.ts`。默认 `bridgent inspect` 不增加 server execution;只有显式 `--probe` 路径读取 summary。

| Test ID | 场景 | 必要断言 | 优先级 |
| --- | --- | --- | --- |
| `POL-CLI-001` | metadata 写工具但未包装 policy | 输出明确标记 `advisory`,不得生成 `policy.enforced: true` | P0 |
| `POL-CLI-002` | summary 包含 `policy.enforced: true` | 输出 enforced 和规范化 declarative rules,且与 advisory warning 分区 | P0 |
| `POL-CLI-003` | policy marker/summary absent | 不把 metadata warning 误报为 enforcement;不引入含义未冻结的 `enabled` 状态 | P0 |
| `POL-CLI-004` | missing audit/preview + enforced requirement | 同时展示 metadata 风险和实际 enforcement 状态,不泄露输入/context | P1 |
| `POL-CLI-005` | default inspect(无 `--probe`) | 不启动第二次 server;保留现有 opt-in 提示 | P0 |
| `POL-CLI-006` | policy summary malformed | probe parser 安全拒绝并回退,不崩溃、不显示伪 enforcement | P0 |

## Gate 与测试卫生

| Test ID | 命令/检查 | 通过条件 | 优先级 |
| --- | --- | --- | --- |
| `POL-G-001` | `pnpm turbo run build test typecheck lint` | 退出码 0;所有 workspace task 执行成功 | P0 |
| `POL-G-002` | `rg -n 'passWithNoTests|\\.skip\\(|\\.only\\(' packages --glob 'package.json' --glob '*.{ts,tsx,js,mjs}' --glob '!**/dist/**'` | 无命中;若需要例外必须先由测试负责人书面批准 | P0 |
| `POL-G-003` | 测试代码审计 | 无外网 URL 调用、真实 secret、未控制 timer、依赖执行顺序的共享状态 | P0 |
| `POL-G-004` | `git diff --check` | 无 whitespace error | P1 |

## 2026-08-17 no-policy baseline

基线 commit:`2a416dbba1c9416f3069e20b8b396051179b0d0e`(`origin/main`)。

环境:Darwin 25.6.0 arm64、Node 25.2.1(满足 Node >=22.18)、pnpm 10.34.1、Turbo 2.9.16。

执行命令:

```bash
pnpm install --frozen-lockfile
pnpm turbo run build test typecheck lint
```

结果:30/30 Turbo tasks 成功,7 个 Vitest workspace、28 个 test files、132 个 tests 全绿;docs build、全部 typecheck/lint 通过。未访问外网测试端点,未使用真实凭证。安装阶段在首次 build 前出现 workspace CLI bin 尚未生成的 warning,随 build 生成 `packages/cli/dist/cli.mjs` 后 smoke test 正常通过,不构成 blocker。

基线审计发现 `packages/cli/package.json` 使用 `vitest run --passWithNoTests`。CLI 当时实际执行了 17 个 tests,但该 flag 会允许未来空测试假绿,因此 WEB-33 移除它,并要求最终 gate 在无此 flag 下复验。

WEB-33 修改后复验:同一全量命令 30/30 tasks 成功;core 新增 2 个 baseline tests 后,最终为 29 个 test files、134 个 tests 全绿。CLI 明确以 `vitest run` 执行并通过 17/17 tests;测试卫生扫描和 `git diff --check` 均无命中/错误。

## WEB-36 ADR 合并复核

核心开发 Agent 已按 ADR-033 完成逐项复核:

- [x] Public API 固定为 additive `withPolicy(tools, policy)`;不新增 transport option,不改变 adapter 输出。
- [x] tool/source allow 取交集、deny 取并集,deny 与 safety/limit 高于 allow;固定首个 violation 优先级已回填到 unit tests。
- [x] 包装期负责 immutable config、setup validation、duplicate name 与 `maxTools`;注册期保留全部 tool;调用期在原始 `run` 前执行已编译 decision。
- [x] metadata 只按已启用规则读取;missing、invalid、write safety 不满足分别使用 `METADATA_MISSING`、`METADATA_INVALID`、`AUDIT_REQUIRED` / `PREVIEW_TOKEN_REQUIRED`。
- [x] Runtime 拒绝固定为 `BRIDGENT_POLICY_DENIED` 和精确 MCP `isError: true` 单行 JSON;setup error 不映射为 MCP result。
- [x] `maxTools` 统计原始 surface;rows/output 只比较 adapter 已执行的 hint,output 单位为最终 UTF-8 bytes,missing 默认 deny、显式 ignore 才放行。
- [x] Inspect summary 只冻结 server-level `policy.enforced: true` 与规范化 rules;未包装时 marker/summary absent,不创造额外 `enabled` 状态。

复核结论:矩阵可直接驱动 Stage 2 实现,未删除或弱化任何 P0 行为断言。任何后续删除 P0 覆盖的变更,都必须先用新 ADR 修改 roadmap acceptance criterion,或建立明确的 scope-change Issue。
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"test": "vitest run --passWithNoTests",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint ."
},
Expand Down
Loading