diff --git a/.changeset/tough-beans-test.md b/.changeset/tough-beans-test.md new file mode 100644 index 0000000..de60dec --- /dev/null +++ b/.changeset/tough-beans-test.md @@ -0,0 +1,5 @@ +--- +"@bridgent/cli": patch +--- + +Harden the CLI test gate so an empty test suite fails instead of reporting a successful run. diff --git a/docs/testing/v0.4-policy-acceptance-matrix.md b/docs/testing/v0.4-policy-acceptance-matrix.md new file mode 100644 index 0000000..e17f965 --- /dev/null +++ b/docs/testing/v0.4-policy-acceptance-matrix.md @@ -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>(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 tools, maximum .`。 +- 注册期不筛选、不重命名 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 "": ` 开头,并同时描述期望值与观察值。 + +## 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。 diff --git a/packages/cli/package.json b/packages/cli/package.json index 198cb25..92b143e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,7 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint ." }, diff --git a/packages/core/test/fixtures/policy-tools.ts b/packages/core/test/fixtures/policy-tools.ts new file mode 100644 index 0000000..2d202ae --- /dev/null +++ b/packages/core/test/fixtures/policy-tools.ts @@ -0,0 +1,66 @@ +import type { BridgentTool } from '../../src' +import { z } from 'zod' +import { defineTool } from '../../src' + +export interface PolicyFixtureRuns { + read: (input: { id: string }) => unknown | Promise + unsafeWrite: (input: { id: string }) => unknown | Promise + safeWrite: (input: { id: string }) => unknown | Promise + noMetadata: (input: { id: string }) => unknown | Promise +} + +type PolicyFixtureTool = BridgentTool<{ id: z.ZodString }, unknown> + +export interface PolicyFixtureTools { + read: PolicyFixtureTool + unsafeWrite: PolicyFixtureTool + safeWrite: PolicyFixtureTool + noMetadata: PolicyFixtureTool +} + +export function createPolicyFixtureTools(runs: PolicyFixtureRuns): PolicyFixtureTools { + return { + read: defineTool({ + name: 'manual_user_get', + metadata: { + source: { kind: 'zod', name: 'manual', reference: 'user.get' }, + capability: 'read', + limits: { rowLimit: 25, outputLimit: 4096 }, + }, + inputSchema: z.object({ id: z.string() }), + run: runs.read, + }), + unsafeWrite: defineTool({ + name: 'openapi_createThing', + metadata: { + source: { kind: 'openapi', name: 'fixtures', reference: 'POST /things' }, + capability: 'write', + safety: { + requiresAuthOrContext: true, + hasAudit: false, + hasPreviewToken: false, + }, + }, + inputSchema: z.object({ id: z.string() }), + run: runs.unsafeWrite, + }), + safeWrite: defineTool({ + name: 'prisma_user_create', + metadata: { + source: { kind: 'prisma', name: 'fixtures', reference: 'User.create' }, + capability: 'write', + safety: { + hasAudit: true, + hasPreviewToken: true, + }, + }, + inputSchema: z.object({ id: z.string() }), + run: runs.safeWrite, + }), + noMetadata: defineTool({ + name: 'legacy_user_get', + inputSchema: z.object({ id: z.string() }), + run: runs.noMetadata, + }), + } +} diff --git a/packages/core/test/policy-baseline.test.ts b/packages/core/test/policy-baseline.test.ts new file mode 100644 index 0000000..00f9eb6 --- /dev/null +++ b/packages/core/test/policy-baseline.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPolicyFixtureTools } from './fixtures/policy-tools' + +describe('v0.4 policy no-policy baseline', () => { + it('preserves current calls and results without policy [POL-C-001]', async () => { + const runs = { + read: vi.fn(({ id }: { id: string }) => ({ id, operation: 'read' })), + unsafeWrite: vi.fn(({ id }: { id: string }) => ({ id, operation: 'unsafe-write' })), + safeWrite: vi.fn(({ id }: { id: string }) => ({ id, operation: 'safe-write' })), + noMetadata: vi.fn(({ id }: { id: string }) => ({ id, operation: 'legacy-read' })), + } + const tools = createPolicyFixtureTools(runs) + + expect(await tools.read.run({ id: 'u1' })).toEqual({ id: 'u1', operation: 'read' }) + expect(await tools.unsafeWrite.run({ id: 'u2' })).toEqual({ id: 'u2', operation: 'unsafe-write' }) + expect(await tools.safeWrite.run({ id: 'u3' })).toEqual({ id: 'u3', operation: 'safe-write' }) + expect(await tools.noMetadata.run({ id: 'u4' })).toEqual({ id: 'u4', operation: 'legacy-read' }) + + expect(runs.read).toHaveBeenCalledOnce() + expect(runs.unsafeWrite).toHaveBeenCalledOnce() + expect(runs.safeWrite).toHaveBeenCalledOnce() + expect(runs.noMetadata).toHaveBeenCalledOnce() + }) + + it('locks representative metadata and metadata-less fixture shapes', () => { + const run = vi.fn() + const tools = createPolicyFixtureTools({ + read: run, + unsafeWrite: run, + safeWrite: run, + noMetadata: run, + }) + + expect(tools.read.metadata).toEqual({ + source: { kind: 'zod', name: 'manual', reference: 'user.get' }, + capability: 'read', + limits: { rowLimit: 25, outputLimit: 4096 }, + }) + expect(tools.unsafeWrite.metadata?.safety).toEqual({ + requiresAuthOrContext: true, + hasAudit: false, + hasPreviewToken: false, + }) + expect(tools.safeWrite.metadata?.safety).toEqual({ + hasAudit: true, + hasPreviewToken: true, + }) + expect(tools.noMetadata.metadata).toBeUndefined() + }) +})