From e05f44afb63697486565be01cea1543f28fd7b09 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Wed, 26 Aug 2026 09:16:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?[REQ-131]=20#1129=20reopen:=20=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=96=87=E6=A1=A3=E8=BD=B4=E6=8A=B5=E8=BE=BE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E6=89=A7=E8=A1=8C=E5=92=BD=E5=96=89=20+=20dy?= =?UTF-8?q?namic=20tool=20policy=20inventory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- .../design.md | 29 +- packages/opencode/src/mcp/index.ts | 26 + .../src/permission/alpha-tool-inventory.ts | 248 +++++++++ .../src/permission/alpha-tool-policy-gate.ts | 209 ++++++++ .../src/permission/alpha-tool-policy.ts | 44 +- packages/opencode/src/permission/index.ts | 9 + packages/opencode/src/session/llm.ts | 30 +- packages/opencode/src/session/llm/request.ts | 27 +- packages/opencode/src/session/prompt.ts | 34 +- packages/opencode/src/session/tools.ts | 61 ++- packages/opencode/src/tool/code-mode.ts | 49 +- packages/opencode/src/tool/registry.ts | 33 +- .../test/fixture/alpha-tool-policy.ts | 42 ++ .../permission/alpha-tool-inventory.test.ts | 350 +++++++++++++ .../opencode/test/provider/transform.test.ts | 11 + .../alpha-subtask-attachment-policy.test.ts | 37 ++ packages/opencode/test/session/prompt.test.ts | 1 + .../alpha-725-policy-chokepoints.cases.ts | 47 +- .../tool/alpha-code-mode-child-policy.test.ts | 73 +++ .../tool/alpha-mcp-websearch-gate.test.ts | 12 +- .../alpha-session-tools-alias-lock.test.ts | 2 + .../test/tool/alpha-tool-identity.test.ts | 17 +- .../alpha-tool-policy-doc-axis-gate.test.ts | 495 ++++++++++++++++++ .../alpha-tool-policy-execution-gate.test.ts | 19 + .../test/tool/code-mode-integration.test.ts | 19 +- packages/opencode/test/tool/code-mode.test.ts | 54 +- packages/schema/src/alpha-tool-inventory.ts | 112 ++++ scripts/gate-files.tsv | 8 +- 29 files changed, 2007 insertions(+), 93 deletions(-) create mode 100644 packages/opencode/src/permission/alpha-tool-inventory.ts create mode 100644 packages/opencode/src/permission/alpha-tool-policy-gate.ts create mode 100644 packages/opencode/test/fixture/alpha-tool-policy.ts create mode 100644 packages/opencode/test/permission/alpha-tool-inventory.test.ts create mode 100644 packages/opencode/test/tool/alpha-tool-policy-doc-axis-gate.test.ts create mode 100644 packages/schema/src/alpha-tool-inventory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fff27e519a3..2f5b5ba65fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Fixed -- **把某个工具设成「询问」或「禁用」,现在在执行那一刻真的管用**(REQ-131 #1129,修 #1121,需下个签名版本生效)——此前工具的三态设置只有**模型目录**那一半是真的:设成「禁用」的工具确实不再出现在模型可见的工具清单里,但只要一个调用真的发起(模型缓存了旧清单、工作流预批清单、直接子任务),内置工具、本地插件工具、宿主 MCP 资源工具就**照跑不误**——设成「询问」也一样,不弹窗、不等待,文件照写、插件照执行。现在这四类工具与 MCP 工具走同一道执行闸:「禁用」在执行当刻响亮拒绝,插件钩子看不到这次调用、一个字节的副作用都不发生;「询问」先挂起等你批准,批准前文件不落盘、远端计数不增长,拒绝即按 fail-closed 收口;「允许」不加打扰,原有的读/写/命令行等能力审批一个不少。同一道闸覆盖 Code Mode 子工具(询问在插件钩子之前解析)与绕过常规装配的直接子任务(此前那里只认「禁用」,「询问」会被静默放行);MCP 工具此前存在的「一次批准要问两遍」也一并修复(审批上移后原地的重复询问移除)。诚实的边界:①三态设置的**编辑界面**是另一张票(#1130),本次生效的是 `opencode.json` 里手写的 identity 规则与桌面既有的强制注入(如联网搜索 kill-switch);②结构化输出的内部协议工具(`StructuredOutput`)按基线强制可用——任何禁用规则都移除不了它,否则「按 JSON 格式回答」会静默失效;它自身没有外部副作用;③附件读取(你亲手选给对话的文件)按基线**明确排除**在工具策略之外,并有负向测试锁死这一排除。 +- **把某个工具设成「询问」或「禁用」,现在在执行那一刻真的管用**(REQ-131 #1129,修 #1121,需下个签名版本生效)——此前工具的三态设置只有**模型目录**那一半是真的:设成「禁用」的工具确实不再出现在模型可见的工具清单里,但只要一个调用真的发起(模型缓存了旧清单、工作流预批清单、直接子任务),内置工具、本地插件工具、宿主 MCP 资源工具就**照跑不误**——设成「询问」也一样,不弹窗、不等待,文件照写、插件照执行。现在这四类工具与 MCP 工具走同一道执行闸:「禁用」在执行当刻响亮拒绝,插件钩子看不到这次调用、一个字节的副作用都不发生;「询问」先挂起等你批准,批准前文件不落盘、远端计数不增长,拒绝即按 fail-closed 收口;「允许」不加打扰,原有的读/写/命令行等能力审批一个不少。同一道闸覆盖 Code Mode 子工具(询问在插件钩子之前解析)与绕过常规装配的直接子任务(此前那里只认「禁用」,「询问」会被静默放行);MCP 工具此前存在的「一次批准要问两遍」也一并修复(审批上移后原地的重复询问移除)。本票 reopen 后补齐的另一半(2026-08-26):**分层工具策略本体**(#1128 落的三态文档:整类 / 单个服务 / 单个工具,含设备管理策略与损坏隔离)现在真的抵达目录与执行两道咽喉 —— 按基线默认,**第三方 MCP、Alpha Cloud 与插件工具首次使用前会先询问**(批准一次或「总是允许」即不再打扰;本地内置工具默认放行不变);把某个 MCP 服务设为「启用」绑定的是**当下这份服务配置**,同名服务换了地址(rebind)后自动回到询问,不沿用旧授权;策略文件损坏时按 fail-closed 把用户可配置工具全部停用(内置协议工具除外),恢复入口是重置(原文件留备份)。同时交付 Settings「工具」节(#1130)要消费的动态工具清单 API:身份、可信来源、每层生效状态与原因(9 型)、绑定变更、「新发现」徽标与「身份无法核验」计数,清单说 disabled 的工具,执行面就是会拒——两面同一个判定器。诚实的边界:①三态设置的**编辑界面**仍是另一张票(#1130),本轮策略文档尚无图形入口,`opencode.json` 手写的 identity 规则与桌面既有强制注入(如联网搜索 kill-switch)照常生效且永远压得过放宽;②结构化输出的内部协议工具(`StructuredOutput`)按基线强制可用——任何禁用规则都移除不了它,否则「按 JSON 格式回答」会静默失效;它自身没有外部副作用;③附件读取(你亲手选给对话的文件)按基线**明确排除**在工具策略之外,并有负向测试锁死这一排除。 - **代理节点模型清单拉不下来时,现在会明说**(#1084,需下个签名版本生效)——应用启动、登录之后都会向平台同步一次「代理节点」模型清单;此前这次同步失败是**完全静默**的:网关限流、鉴权过期、服务不可达、返回格式对不上,主进程都把原因算得清清楚楚,然后原地扔掉(两处 `.catch(() => {})`,而那条能把结果送给界面的通道一个调用方都没有)。结果是模型选择器安安静静停在上次拿到的清单或安装包自带的清单上——新上架的模型不出现、已下架的还在、计价倍数显示成「暂不可用」,而你完全不知道是同步失败了,只会以为清单本来就长这样。现在这类失败会在窗口顶部显示一条提示,写明「代理节点模型清单刷新失败」、当前显示的是上次拿到的清单、自带 KEY 的节点不受影响,并带上平台给出的原因标识(如 `rate_limited` / `unauthorized` / `http-503`)——报障时可以直接把它贴出来。下一次同步成功,提示自动消失。诚实的边界:①这只是让失败可见,**没有**新增自动重试——刷新点仍然是启动与登录后那两处,「打开选择器不刷新」这个既有缺陷本次不碰;②自带 KEY(BYOK)的节点与上次成功缓存的清单照常可用,所以这是一条降级提示而不是错误阻断;③平台契约不兼容这一种失败早就有自己的提示条,两条不会同时叠在一起。 - **隔离预览里被挡掉的外部资源,现在会如实告诉你**(#907,需下个签名版本生效)——在隔离窗口里预览 HTML 报告时,图片、字体、外链会被安全策略拦下,而此前界面上一个字都没有:这些拦截主进程一直逐条记着,预览卡却从不去读它,于是图表变成碎块、样式全丢,你只会以为这份报告本身是坏的。现在预览打开期间,卡片上会显示「已阻止 N 项外部资源」并列出被挡的条目;清单到达记录上限时说的是「至少 N 项」,不会拿一个被截断的数字冒充完整计数。同时新增一个「复制清单」按钮——这是把这些地址带出隔离窗口的唯一出口,且只在你按下时才发生。诚实的边界:①条目是**来源域或本次任务目录内的相对路径**,不是文档请求的完整链接(隔离宿主刻意只记来源域、不留路径与查询串,以免不可信文档借这个面把数据带出去),被拒绝的**页面跳转**则根本不入记录——所以这里回答的是「哪些来源被挡了」,不是「你刚点的那个链接是什么」;②隔离窗口的拦截一条都没有放宽:它依旧不开新窗口、不跳转、不发任何外部请求、不下载、不给任何权限,预览里也依然没有任何通往系统浏览器的路径。 diff --git a/docs/design/2026-08-25-req131-settings-tool-policy/design.md b/docs/design/2026-08-25-req131-settings-tool-policy/design.md index cf4bb1ddaec2..9a7bf66618dd 100644 --- a/docs/design/2026-08-25-req131-settings-tool-policy/design.md +++ b/docs/design/2026-08-25-req131-settings-tool-policy/design.md @@ -79,10 +79,10 @@ hub-settings 时代的「仅 通用 + 快捷键」两项;现役实现已有第 | 损坏恢复横幅 + 重置 | user layer `quarantined` + `reset()`(返回备份路径) | 已落地 | | 逐条保存失败 | `ToolPolicyWriteError`(含 quarantine 拒写) | 已落地 | | 「仅当前账户与当前项目」 | `inspect().partition` | 已落地 | -| 每类 / 每服务条数、工具清单本身 | live inventory(§5:identity + 可信 authority + 继承 + effective + binding change) | **未落地 —— 见 §9 更正** | -| 「新发现」徽标 | 同上(§3:无 broad override 的新动态工具标「新发现」) | **未落地 —— 见 §9 更正** | +| 每类 / 每服务条数、工具清单本身 | live inventory(§5:identity + 可信 authority + 继承 + effective + binding change) | 已落地(#1129 reopen;`AlphaToolInventory.list()`,wire 形状 `packages/schema/src/alpha-tool-inventory.ts`,闸 `test/permission/alpha-tool-inventory.test.ts` I1) | +| 「新发现」徽标 | 同上(§3:无 broad override 的新动态工具标「新发现」) | 已落地(#1129 reopen;`tools[].newlyDiscovered`,闸 I1) | | 「计费:按用量 / 未知」 | `ToolBillingFact { class, evidenceId }`(宿主 / 服务端可信事实;缺失显示「未知」) | schema 已落地;供给随 inventory | -| 「1 项注册身份无法核验」 | resolver `invalid-identity`;条目枚举依赖 inventory 是否暴露此类注册 | **未落地 —— 见 §9 更正**(Q1 已裁 = 暴露计数,见 §8) | +| 「1 项注册身份无法核验」 | resolver `invalid-identity`;条目枚举依赖 inventory 是否暴露此类注册 | 已落地(#1129 reopen;`invalid.count` + `entries[].detail` 归开发者详情,Q1 裁决原样;闸 I1) | | 「已核验」徽标(Alpha Cloud 组) | `ToolAuthority.kind = "alpha-cloud"`(verified) | 已落地 | reason → 文案(全部 9 型,一型不落): @@ -235,7 +235,28 @@ Settings 面板显示「已停用」,而模型照调 —— 正是 `#1121` 那 「Settings 和调用方不得手拼 wildcard」「禁止各处拼 `mcp::*`」;且 binding guard (enabled + digest,rebind 回 ask)与 9 型 reason 在 ruleset 语言里**不可表达**。 +### 缺口补齐(2026-08-26,#1129 reopen 交付) + +上表三行由 `#1129` reopen 后的交付补齐 —— 这次不是看 issue 状态,是看得见的落点: + +- **inventory/API**:`packages/opencode/src/permission/alpha-tool-inventory.ts` + (`AlphaToolInventory.Service.list()`,从 live ToolRegistry / MCP / host 谓词派生); + wire 形状(renderer 可 import)在 `packages/schema/src/alpha-tool-inventory.ts`, + 返回值经 schema decode 自证。常驻闸 `test/permission/alpha-tool-inventory.test.ts` + (I1–I5:成员/徽标真话/binding change/cap 折叠/quarantine)。 +- **策略文档轴抵达咽喉**:目录(`session/llm/request.ts`)与执行 + (`session/tools.ts` identityGate、`tool/code-mode.ts` child、`session/prompt.ts` subtask) + 经同一 `alpha-tool-policy-gate.ts` 消费同一 resolver;executor 调用时重读。 + 常驻闸 `test/tool/alpha-tool-policy-doc-axis-gate.test.ts`(D1–D7,含 held 对象重读与 + rebind 回 ask 的变异式判据)。 +- **本节 `setRecord` 写下的 disabled/ask,目录与执行面现在读得到** —— §9 指出的 + 「面板显示已停用而模型照调」在引擎侧已不可达(D3/D4/I2)。 + +仍然悬空的一跳(#1130 落地前要自己解决的):引擎 API ↔ main/preload/renderer 的**传输**。 +sidecar 的 HTTP 面(`httpapi/api.ts`)是上游文件,新增 route 需要一次 ADR 级收编; +这属于 #1130 的「main/preload/renderer」边界,不在 #1129 的边界文件清单内。 + ### 本稿的状态 `status: approved` **保留** —— 视觉、交互规范、9 型文案、六个状态帧都仍然是基线。 -变的只是:**§3 表里那三行回到「契约,未落地」**,实现票必须等缺口补齐。 +§3 表里那三行曾回到「契约,未落地」;2026-08-26 由 #1129 reopen 交付补齐(见上节坐标)。 diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index a3077b66c7c2..310506a8c870 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -188,6 +188,20 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> + /** + * #1129(REQ-131)—— 某个 MCP 配置键(= `ToolIdentity.origin`)的**当前** binding 事实: + * 生效的配置 entry(动态 `add` 的覆盖静态 config)与当前 authority。执行咽喉在每次调用时 + * 由此重新派生 binding digest(§5:rebind 后旧的 service/tool enabled 必须失效回 ask), + * inventory 由此给 Settings 发放写 enabled 记录所需的 digest。entry 完全未知返回 undefined + * (⇒ 调用方派生不出 digest ⇒ 依赖 digest 的放宽按 binding-changed 回 ask,fail-closed)。 + * + * 方法**可选**是刻意的:上游测试里存量的 `MCP.Service.of` 全量 stub(north-star 不许改) + * 不必逐个补桩 —— 方法缺席与「facts 未知」同义,方向只会更严(派生不出当前 digest ⇒ + * enabled 记录按 binding-changed 回 ask),永远不会更松。生产 layer 恒有实现。 + */ + readonly bindingFacts?: ( + origin: string, + ) => Effect.Effect<{ entry?: ConfigMCPV1.Info; authority: ToolAuthority } | undefined> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> readonly resourceTemplates: ( @@ -725,6 +739,17 @@ const layer = Layer.effect( return result }) + const bindingFacts = Effect.fn("MCP.bindingFacts")(function* (origin: string) { + const s = yield* InstanceState.get(state) + const cfg = yield* cfgSvc.get() + const configured = (cfg.mcp ?? {})[origin] + const raw = s.config[origin] ?? configured + const entry = raw !== undefined && isMcpConfigured(raw) ? raw : undefined + const authority = s.authority[origin] + if (entry === undefined && authority === undefined) return undefined + return { entry, authority: authority ?? ({ kind: "not-asserted" } as const) } + }) + function collectFromConnected( s: State, listFn: (c: Client, timeout?: number) => Promise, @@ -1044,6 +1069,7 @@ const layer = Layer.effect( clients, instructions, tools, + bindingFacts, prompts, resources, resourceTemplates, diff --git a/packages/opencode/src/permission/alpha-tool-inventory.ts b/packages/opencode/src/permission/alpha-tool-inventory.ts new file mode 100644 index 000000000000..fb889d3863b5 --- /dev/null +++ b/packages/opencode/src/permission/alpha-tool-inventory.ts @@ -0,0 +1,248 @@ +// alpha 自有文件(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// REQ-131 / #1129(reopen)—— **dynamic tool policy inventory**(#724 §5 末条)。 +// +// 从 **live registry/materialization** 派生:V1 ToolRegistry(builtin/plugin/host 门面)、 +// MCP.tools()(当前连接的 server 与工具)、以及仅当有 resource-capable server 时才存在的 +// 三个 host 伪工具(与 `session/tools.ts` 同一谓词)。**不读**历史 `ToolPart.display`、 +// 不读 UI 名单(§5 禁令)。 +// +// effective 判定与咽喉是**同一个 resolver**(§6):同一份 `resolveToolPolicy` 纯核、同一个 +// `effectiveFromSnapshot` 合成(ruleset 轴 deny 折 cap)。差别只有 §6 允许的那一条 —— +// inventory 是当轮 snapshot,executor 逐次重读。所以 Settings 徽标显示的 disabled/ask, +// 就是 executor 下一次调用会做的事。 +// +// ruleset 轴取**全局 config** 的 `permission`(`Permission.fromConfig(cfg.permission)`)—— +// Settings 是长期策略面板,不进任何会话态;per-agent 附加规则与 session 规则只会在执行时 +// **更严**,不会更松(deny 折 cap、ask 只多问),方向 fail-closed。 +// +// 返回值先过 `parseToolPolicyInventory`(schema decode)再交出:引擎侧形状漂移 loud fail, +// 不让 Settings 拿到解释不了的对象。 +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import { canonicalToolIdentity, type ToolAuthority, type ToolIdentity } from "@opencode-ai/schema/tool-identity" +import { + classifyTool, + selectorKey, + selectorMatches, + type ToolClass, + type ToolPolicyRecord, + type ToolPolicySubject, +} from "@opencode-ai/schema/alpha-tool-policy" +import { + parseToolPolicyInventory, + type EffectiveToolPolicyV1, + type ToolPolicyInventoryServiceV1, + type ToolPolicyInventoryToolV1, + type ToolPolicyInventoryV1, +} from "@opencode-ai/schema/alpha-tool-inventory" +import { Config } from "@/config/config" +import { MCP } from "@/mcp" +import { ToolRegistry } from "@/tool/registry" +import { Permission } from "./index" +import { AlphaToolPolicy, mcpBindingDigest, type EffectiveToolPolicy } from "./alpha-tool-policy" +import { AlphaToolPolicyGate } from "./alpha-tool-policy-gate" + +const MCP_RESOURCE_HOST_TOOLS = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"] as const + +/** 9 型逐型转换(exhaustive switch = 编译期漂移闸:resolver 加第 10 型,这里当场红)。 */ +function wireReason(reason: EffectiveToolPolicy["reason"]): EffectiveToolPolicyV1["reason"] { + switch (reason.kind) { + case "invalid-identity": + return { kind: "invalid-identity", detail: reason.detail } + case "cap-managed": + return { kind: "cap-managed" } + case "cap-managed-unreadable": + return { kind: "cap-managed-unreadable", detail: reason.detail } + case "cap-entitlement": + return { kind: "cap-entitlement", verdict: reason.verdict } + case "cap-hard-deny": + return { kind: "cap-hard-deny", sources: [...reason.sources] } + case "quarantine": + return { kind: "quarantine", detail: reason.detail } + case "user": + return { kind: "user", level: reason.level } + case "binding-changed": + return { kind: "binding-changed", level: reason.level } + case "default": + return { kind: "default", class: reason.class } + } +} + +function wireEffective(effective: EffectiveToolPolicy): EffectiveToolPolicyV1 { + return { state: effective.state, action: effective.action, reason: wireReason(effective.reason) } +} + +const DYNAMIC_CLASSES: ReadonlySet = new Set(["alpha-cloud", "third-party-mcp", "plugin"]) + +export interface Interface { + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/AlphaToolInventory") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const mcp = yield* MCP.Service + const config = yield* Config.Service + const policy = yield* AlphaToolPolicy.Service + + type LiveTool = { + technicalId: string + identity: ToolIdentity + authority: ToolAuthority + bindingDigest?: string + } + + const list: Interface["list"] = Effect.fn("AlphaToolInventory.list")(function* () { + const snap = yield* policy.snapshot() + const cfg = yield* config.get() + const ruleset = Permission.fromConfig(cfg.permission ?? {}) + + // ── live 枚举 ──────────────────────────────────────────────────────────── + const live: LiveTool[] = [] + const invalid: { technicalId: string; detail: string }[] = [] + + for (const item of yield* registry.all()) { + const bindingDigest = + item.identity.source === "plugin" + ? yield* registry.pluginBinding(item.identity.origin) + : AlphaToolPolicyGate.APP_BUILTIN_BINDING_DIGEST + live.push({ technicalId: item.id, identity: item.identity, authority: { kind: "not-asserted" }, bindingDigest }) + } + + const hasMcpResourceServer = Object.values(yield* mcp.clients()).some( + (client) => !!client.getServerCapabilities()?.resources, + ) + if (hasMcpResourceServer) { + for (const name of MCP_RESOURCE_HOST_TOOLS) { + live.push({ + technicalId: name, + identity: { source: "host", origin: "", name }, + authority: { kind: "not-asserted" }, + bindingDigest: AlphaToolPolicyGate.APP_BUILTIN_BINDING_DIGEST, + }) + } + } + + const mcpDigests = new Map() + for (const [alias, entry] of Object.entries(yield* mcp.tools())) { + if (!entry.identity || !entry.authority) { + invalid.push({ technicalId: alias, detail: "MCP tool is missing its source identity" }) + continue + } + if (!mcpDigests.has(entry.identity.origin)) { + const facts = mcp.bindingFacts ? yield* mcp.bindingFacts(entry.identity.origin) : undefined + mcpDigests.set( + entry.identity.origin, + facts?.entry ? mcpBindingDigest(entry.identity.origin, facts.entry) : undefined, + ) + } + live.push({ + technicalId: alias, + identity: entry.identity, + authority: entry.authority, + bindingDigest: mcpDigests.get(entry.identity.origin), + }) + } + + // ── 用户记录索引(§3:同一 selector 只有一条)───────────────────────────── + const records: readonly ToolPolicyRecord[] = snap.user.status === "ok" ? snap.user.records : [] + const recordBySelectorKey = new Map(records.map((record) => [selectorKey(record.selector), record])) + const classRecords = records.filter((record) => record.selector.level === "class") + + // ── 逐工具判定 + 按 (source, origin) 分组 ──────────────────────────────── + const services = new Map() + for (const item of live) { + let canonical: string + let cls: ToolClass | undefined + try { + canonical = canonicalToolIdentity(item.identity) + cls = classifyTool({ identity: item.identity, authority: item.authority }) + } catch (error) { + invalid.push({ technicalId: item.technicalId, detail: error instanceof Error ? error.message : String(error) }) + continue + } + if (cls === undefined) { + invalid.push({ technicalId: item.technicalId, detail: `unclassifiable subject: ${canonical}` }) + continue + } + const subject: ToolPolicySubject = { + identity: item.identity, + authority: item.authority, + bindingDigest: item.bindingDigest, + } + const effective = AlphaToolPolicyGate.effectiveFromSnapshot(snap, subject, ruleset) + const toolRecord = recordBySelectorKey.get(selectorKey({ level: "tool", canonical })) + const hasAnyOverride = records.some((record) => selectorMatches(record.selector, subject)) + // 写 enabled 记录要携带的 digest:alpha-cloud 用 verified authority 证据(§5), + // 其余用宿主派生的当前 binding —— 与 resolver 的 `subjectBindingDigest` 同一取向。 + const writeDigest = + item.authority.kind === "alpha-cloud" ? item.authority.evidenceDigest : item.bindingDigest + const tool: ToolPolicyInventoryToolV1 = { + canonical, + identity: item.identity, + technicalId: item.technicalId, + authority: item.authority, + ...(writeDigest !== undefined ? { bindingDigest: writeDigest } : {}), + effective: wireEffective(effective), + ...(toolRecord !== undefined ? { record: toolRecord } : {}), + newlyDiscovered: DYNAMIC_CLASSES.has(cls) && !hasAnyOverride, + } + + const serviceKey = selectorKey({ level: "service", source: item.identity.source, origin: item.identity.origin }) + let service = services.get(serviceKey) + if (!service) { + const serviceRecord = recordBySelectorKey.get(serviceKey) + service = { + source: item.identity.source, + origin: item.identity.origin, + class: cls, + authority: item.authority, + ...(writeDigest !== undefined ? { bindingDigest: writeDigest } : {}), + ...(serviceRecord !== undefined ? { record: serviceRecord } : {}), + tools: [], + } + services.set(serviceKey, service) + } + service.tools.push(tool) + } + + for (const service of services.values()) { + service.tools.sort((a, b) => a.canonical.localeCompare(b.canonical)) + } + + const built: ToolPolicyInventoryV1 = { + version: 1, + partition: snap.partition, + user: + snap.user.status === "quarantined" + ? { status: "quarantined", reason: snap.user.reason } + : { status: snap.user.status }, + managed: + snap.managed.status === "unreadable" + ? { status: "unreadable", reason: snap.managed.reason } + : { status: "ok" }, + classRecords, + services: [...services.values()].toSorted( + (a, b) => a.source.localeCompare(b.source) || a.origin.localeCompare(b.origin), + ), + invalid: { count: invalid.length, entries: invalid }, + } + // decode = 对 wire 契约的运行期自证(schema 变了而这里没跟 ⇒ loud fail,不静默漂)。 + return parseToolPolicyInventory(built) + }) + + return Service.of({ list }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer, + deps: [ToolRegistry.node, MCP.node, Config.node, AlphaToolPolicy.node], +}) + +export * as AlphaToolInventory from "./alpha-tool-inventory" diff --git a/packages/opencode/src/permission/alpha-tool-policy-gate.ts b/packages/opencode/src/permission/alpha-tool-policy-gate.ts new file mode 100644 index 000000000000..c2f33899e08c --- /dev/null +++ b/packages/opencode/src/permission/alpha-tool-policy-gate.ts @@ -0,0 +1,209 @@ +// alpha 自有文件(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// REQ-131 / #1129(reopen)—— 策略**文档轴**抵达目录与执行咽喉的共享入口(#724 §3/§4/§6)。 +// +// ── 两条轴与合成次序(#724 §4「deny 是上限,ask 是待裁决态」;不自创)──────────── +// · **ruleset 轴**:config/agent/session 的 Permission ruleset(#1121/#1135 已接进咽喉的 +// identity 规则)。它对某个 exact canonical 的 **deny** 按 §4 第 1 步折进 cap +// (`hardDeny: ["permission-ruleset"]`)—— deny 是上限,任何文档轴 enabled、任何 +// session grant 都撬不开;它的 ask/allow 留在 `Permission.ask` 原地生效(既有闸保留)。 +// · **文档轴**:`AlphaToolPolicy.resolve`(#1128)—— caps(managed/entitlement/hard-deny)→ +// 用户 selector(tool > service > class,binding guard)→ 四类默认(§2 表:本地 enabled, +// 其余 ask)。 +// · 合成结果只有三种,全部编译进**同一个** Permission 引擎调用(§2:不另造第二个审批引擎): +// deny ⇒ 具名响亮拒绝(`PermissionV1.DeniedError`,载明 canonical 与 reason), +// 零 hook / 零副作用;session grant 结构性接触不到它。 +// ask ⇒ 在这一次 `Permission.ask` 的 ruleset 末尾追加一条 exact-canonical 的 ask +// (findLast 语义下压过默认 allow 底)⇒ 挂起等批准;once/always 的会话语义 +// 原样是 #1128 的 grants。ruleset 轴自己的 ask 同样在这一问里生效 —— +// **一次调用恰好一问**(§6 E3 的去重不回潮)。 +// allow ⇒ 文档轴不加 identity prompt;ruleset 轴与工具自身 ability 闸原样保留(§6)。 +// +// ── snapshot vs 每次重读(§6)───────────────────────────────────────────────── +// 目录(catalog / DWS 预批清单 / inventory)可用**当轮 snapshot**(`snapshotEffective*`); +// **executor 必须在调用时重读**(`gateToolExecution` 每次调用走 `resolve()`,它重读 +// managed cap 与用户文档;MCP 的当前 binding 也在调用时重新派生)—— Settings/managed/ +// kill-switch 在模型拿到旧 catalog 后收紧,缓存的工具对象也执行不了。 +// +// ── 为什么 gate 收**服务句柄**而不是 yield 服务 ─────────────────────────────── +// code-mode 的子工具闸跑在 ToolRegistry 自己 materialize 的工具里 —— 若本模块 yield* +// ToolRegistry.Service,类型上就是 registry 依赖自己(层图自环)。调用方把自己作用域里 +// 已有的句柄递进来,subject 的派生(mcp / plugin / builtin)也由调用方用自己的句柄完成。 +import { Effect } from "effect" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { canonicalToolIdentity, type ToolAuthority, type ToolIdentity } from "@opencode-ai/schema/tool-identity" +import type { ToolPolicySubject } from "@opencode-ai/schema/alpha-tool-policy" +import { Permission } from "./index" +import { + AlphaToolPolicy, + resolveToolPolicy, + type EffectiveToolPolicy, + type ToolPolicyCaps, + type ToolPolicyUserLayer, +} from "./alpha-tool-policy" +import { canonicalJsonDigest } from "./alpha-tool-policy-store" +import type { ManagedPolicyResult } from "./alpha-managed-policy" + +/** ruleset 轴 deny 折进 cap 时的来源名(出现在 `cap-hard-deny.sources` 与 Settings 原因里)。 */ +export const RULESET_HARD_DENY_SOURCE = "permission-ruleset" + +/** + * builtin / host / builtin-v2 的 binding(§5):它们随应用本体一起装载,没有独立的 + * rebind 生命周期 —— 常量证据 ⇒ service/tool 层的 enabled 记录不因版本升级失效。 + */ +export const APP_BUILTIN_BINDING_DIGEST = canonicalJsonDigest({ kind: "alpha-app-builtin" }) + +export function builtinSubject(identity: ToolIdentity, authority: ToolAuthority): ToolPolicySubject { + return { identity, authority, bindingDigest: APP_BUILTIN_BINDING_DIGEST } +} + +/** + * ruleset 轴对 exact canonical 的判定 —— 用**引擎自己的** `Permission.evaluate` + * (identity ask 的 patterns 恒为 `["*"]`,与执行时同一形参),不手写第二个匹配器。 + */ +export function rulesetIdentityAction(canonical: string, ruleset: PermissionV1.Ruleset): PermissionV1.Action { + return Permission.evaluate(canonical, "*", ruleset).action +} + +function foldRulesetCap( + subject: ToolPolicySubject, + ruleset: PermissionV1.Ruleset, +): { hardDeny: readonly string[] } { + let canonical: string + try { + canonical = canonicalToolIdentity(subject.identity) + } catch { + // identity 铸不出 canonical ⇒ resolver 自己会判 invalid-identity/disabled,无需折 cap。 + return { hardDeny: [] } + } + return rulesetIdentityAction(canonical, ruleset) === "deny" ? { hardDeny: [RULESET_HARD_DENY_SOURCE] } : { hardDeny: [] } +} + +/** 文档轴 + ruleset-deny cap 的合成判定(executor 侧:调用方每次调用重新进入,`resolve` 重读)。 */ +export const effectiveToolPolicyNow = ( + policy: AlphaToolPolicy.Interface, + subject: ToolPolicySubject, + ruleset: PermissionV1.Ruleset, +): Effect.Effect => policy.resolve(subject, foldRulesetCap(subject, ruleset)) + +/** 同一合成,但对**已取好的 snapshot** 求值(目录 / 预批 / inventory;§6 允许当轮 snapshot)。 */ +export function effectiveFromSnapshot( + snapshot: { managed: ManagedPolicyResult; user: ToolPolicyUserLayer }, + subject: ToolPolicySubject, + ruleset: PermissionV1.Ruleset, +): EffectiveToolPolicy { + const caps: ToolPolicyCaps = { managed: snapshot.managed, hardDeny: foldRulesetCap(subject, ruleset).hardDeny } + return resolveToolPolicy({ subject, caps, user: snapshot.user }) +} + +function describeIdentity(identity: ToolIdentity): string { + try { + return canonicalToolIdentity(identity) + } catch { + return `${identity.source}:${identity.origin}:${identity.name}` + } +} + +function namedDeny(identity: ToolIdentity, effective: EffectiveToolPolicy): PermissionV1.DeniedError { + // `ruleset` 载荷是 Schema.Any:这里放一条自述来源的记录,让 DeniedError.message + // (JSON.stringify(ruleset))**点名 canonical 与拒绝原因** —— 用户与模型读到的是同一句话。 + return new PermissionV1.DeniedError({ + ruleset: [ + { + permission: describeIdentity(identity), + pattern: "*", + action: "deny", + source: "alpha-tool-policy", + reason: effective.reason.kind, + }, + ], + }) +} + +export interface GateInput { + readonly policy: AlphaToolPolicy.Interface + readonly permission: Permission.Interface + /** 调用方派生的**当前**主体(mcp 当前 entry digest / plugin loader digest / builtin 常量)。 */ + readonly subject: Effect.Effect + readonly ruleset: PermissionV1.Ruleset + readonly sessionID: PermissionV1.AskInput["sessionID"] + readonly tool?: { messageID: string; callID: string } + readonly metadata?: Record +} + +/** + * 执行咽喉的统一闸(E1/E2/E3 经 `SessionTools.register`、E4 code-mode child、E6 direct + * subtask 共用)。每次调用重读文档轴与当前 binding;deny 早于任何 hook/副作用。 + */ +export const gateToolExecution = (input: GateInput): Effect.Effect => + Effect.gen(function* () { + const subject = yield* input.subject + const effective = yield* effectiveToolPolicyNow(input.policy, subject, input.ruleset) + if (effective.action === "deny") return yield* namedDeny(subject.identity, effective) + // 到这里 identity 一定铸得出 canonical(否则上面已按 invalid-identity deny)。 + const canonical = canonicalToolIdentity(subject.identity) + const askRuleset: PermissionV1.Ruleset = + effective.action === "ask" + ? [...input.ruleset, { permission: canonical, pattern: "*", action: "ask" }] + : input.ruleset + yield* input.permission.ask({ + permission: canonical, + sessionID: input.sessionID, + metadata: input.metadata ?? {}, + patterns: ["*"], + always: ["*"], + tool: input.tool, + ruleset: askRuleset, + }) + }) + +export interface CatalogSubject { + readonly technicalId: string + readonly identity: ToolIdentity + readonly authority: ToolAuthority +} + +/** + * 目录侧(snapshot):这些工具按文档轴是 **deny** ⇒ 不广告(§6:「disabled:目录不广告」; + * ask 照旧广告)。binding digest 刻意不参与 —— binding guard 只把 enabled 降级成 ask, + * 从不产生 deny,而目录只关心 deny。`host::StructuredOutput` 按 §6 internal sentinel 免疫 + * (exact canonical;缺 identity 不享豁免)。 + */ +export const snapshotCatalogDenied = ( + policy: AlphaToolPolicy.Interface, + subjects: readonly CatalogSubject[], + ruleset: PermissionV1.Ruleset, +): Effect.Effect> => + Effect.gen(function* () { + const snap = yield* policy.snapshot() + const denied = new Set() + for (const item of subjects) { + if (Permission.isInternalSentinelIdentity(item.identity)) continue + const effective = effectiveFromSnapshot(snap, { identity: item.identity, authority: item.authority }, ruleset) + if (effective.action === "deny") denied.add(item.technicalId) + } + return denied + }) + +/** + * DWS 预批清单侧(snapshot;§6 E5「preapproved 只收 effective enabled」):按文档轴给出 + * 每个工具的动作。**刻意不带 binding digest** —— service/tool 层的 enabled 记录在缺 digest + * 时按 binding-changed 降级为 ask ⇒ 不预批。方向是 fail-closed(至多多问,绝不少问); + * executor 侧仍会带着真 digest 二次重读,不多拦一次真实调用。 + */ +export const snapshotDocActions = ( + policy: AlphaToolPolicy.Interface, + subjects: readonly CatalogSubject[], + ruleset: PermissionV1.Ruleset, +): Effect.Effect> => + Effect.gen(function* () { + const snap = yield* policy.snapshot() + const actions = new Map() + for (const item of subjects) { + const effective = effectiveFromSnapshot(snap, { identity: item.identity, authority: item.authority }, ruleset) + actions.set(item.technicalId, effective.action) + } + return actions + }) + +export * as AlphaToolPolicyGate from "./alpha-tool-policy-gate" diff --git a/packages/opencode/src/permission/alpha-tool-policy.ts b/packages/opencode/src/permission/alpha-tool-policy.ts index fd11f5ef2c42..126b24fe91c6 100644 --- a/packages/opencode/src/permission/alpha-tool-policy.ts +++ b/packages/opencode/src/permission/alpha-tool-policy.ts @@ -182,12 +182,33 @@ export interface ResolveCapsInput { readonly hardDeny?: readonly string[] } +/** 当轮 snapshot(§6:目录/预批/inventory 可用;executor 不得拿它替代 `resolve` 的重读)。 */ +export interface ToolPolicySnapshot { + readonly partition: ToolPolicyPartition + readonly managed: ManagedPolicyResult + readonly user: ToolPolicyUserLayer + readonly load: PolicyLoadResult +} + +export function userLayerFrom(load: PolicyLoadResult): ToolPolicyUserLayer { + switch (load.status) { + case "ok": + return { status: "ok", records: load.doc.records } + case "absent": + return { status: "absent" } + case "quarantined": + return { status: "quarantined", reason: load.reason } + } +} + export interface Interface { /** 每次调用重读当前 cap 与用户文档(§6:executor 必须在调用时重读,不缓存旧对象)。 */ readonly resolve: ( subject: ToolPolicySubject, caps?: ResolveCapsInput, ) => Effect.Effect + /** 一次读盘取整份现状(#1129:目录 / DWS 预批 / inventory 的 snapshot 口)。 */ + readonly snapshot: () => Effect.Effect readonly inspect: () => Effect.Effect<{ partition: ToolPolicyPartition; user: PolicyLoadResult }> readonly setRecord: (record: ToolPolicyRecord) => Effect.Effect readonly removeRecord: (selector: ToolPolicySelector) => Effect.Effect @@ -219,23 +240,22 @@ export const layer = (options?: LayerOptions) => return { account, workspace: String(ctx.project.id) } }) + const snapshot = Effect.fn("AlphaToolPolicy.snapshot")(function* () { + const managed = yield* Effect.promise(managedRead) + const part = yield* partition + const loaded = loadPolicyDocument(baseDir, part) + return { partition: part, managed, user: userLayerFrom(loaded), load: loaded } satisfies ToolPolicySnapshot + }) + const resolve = Effect.fn("AlphaToolPolicy.resolve")(function* ( subject: ToolPolicySubject, caps?: ResolveCapsInput, ) { - const managed = yield* Effect.promise(managedRead) - const part = yield* partition - const loaded = loadPolicyDocument(baseDir, part) - const user: ToolPolicyUserLayer = - loaded.status === "ok" - ? { status: "ok", records: loaded.doc.records } - : loaded.status === "absent" - ? { status: "absent" } - : { status: "quarantined", reason: loaded.reason } + const snap = yield* snapshot() return resolveToolPolicy({ subject, - caps: { managed, entitlement: caps?.entitlement, hardDeny: caps?.hardDeny }, - user, + caps: { managed: snap.managed, entitlement: caps?.entitlement, hardDeny: caps?.hardDeny }, + user: snap.user, }) }) @@ -283,7 +303,7 @@ export const layer = (options?: LayerOptions) => return resetPolicyDocument(baseDir, part) }) - return Service.of({ resolve, inspect, setRecord, removeRecord, reset }) + return Service.of({ resolve, snapshot, inspect, setRecord, removeRecord, reset }) }), ) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d90db457c456..3b59100b3c71 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -341,6 +341,15 @@ export type ToolPermissionSubject = { technicalId: string; identity: ToolIdentit // identityGate 约束。 const INTERNAL_SENTINEL_CANONICALS: ReadonlySet = new Set(["host::StructuredOutput"]) +/** #1129 文档轴目录闸的同一豁免口(exact canonical;identity 铸不出 canonical 不享豁免)。 */ +export function isInternalSentinelIdentity(identity: ToolIdentity): boolean { + try { + return INTERNAL_SENTINEL_CANONICALS.has(canonicalToolIdentity(identity)) + } catch { + return false + } +} + export function disabled( tools: readonly (string | ToolPermissionSubject)[], ruleset: PermissionV1.Ruleset, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index af179baddbce..140c06c0657f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -17,6 +17,8 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { Permission } from "@/permission" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Wildcard } from "@/util/wildcard" @@ -34,10 +36,19 @@ import { getToolDisplay } from "./tool-display" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX -export function workflowPreapprovedToolNames(tools: Record, ruleset: PermissionV1.Ruleset) { +// #1129 / #724 §6 E5:「preapproved 列表只收 effective enabled,ask/disabled 均不得预批」。 +// 两条轴都要过:ruleset 轴的 identity ask(既有判据)+ 策略**文档轴**的动作(snapshot)。 +// `docActions` 是**必传**参:漏一个调用点在 typecheck 就红,而不是静默退回单轴。 +// 查不到的 technicalId 按 fail-closed 处理(不预批 —— 至多多问,绝不少问)。 +export function workflowPreapprovedToolNames( + tools: Record, + ruleset: PermissionV1.Ruleset, + docActions: ReadonlyMap, +) { return Object.keys(tools).filter((name) => { const display = getToolDisplay(tools[name]!) if (!display) return false + if (docActions.get(name) !== "allow") return false const identity = canonicalToolIdentity(display.identity) const match = ruleset.findLast((rule) => Wildcard.match(identity, rule.permission)) return !match || match.action !== "ask" @@ -79,6 +90,7 @@ const live: Layer.Layer< | Provider.Service | Plugin.Service | Permission.Service + | AlphaToolPolicy.Service | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service @@ -90,6 +102,7 @@ const live: Layer.Layer< const provider = yield* Provider.Service const plugin = yield* Plugin.Service const perm = yield* Permission.Service + const toolPolicy = yield* AlphaToolPolicy.Service const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service @@ -120,6 +133,7 @@ const live: Layer.Layer< provider: item, auth: info, plugin, + toolPolicy, flags, isWorkflow, }) @@ -159,7 +173,18 @@ const live: Layer.Layer< } const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = workflowPreapprovedToolNames(prepared.tools, ruleset) + // 文档轴动作按当轮 snapshot 求值(§6 允许);subject 刻意不带 binding digest —— + // service/tool 层 enabled 记录会按 binding-changed 降为 ask ⇒ 不预批(fail-closed, + // 至多多问);executor 侧 identityGate 仍带真 digest 逐次重读,不多拦真实调用。 + const docActions = yield* AlphaToolPolicyGate.snapshotDocActions( + toolPolicy, + Object.entries(prepared.tools).flatMap(([technicalId, item]) => { + const display = getToolDisplay(item) + return display ? [{ technicalId, identity: display.identity, authority: display.authority }] : [] + }), + ruleset, + ) + workflowModel.sessionPreapprovedTools = workflowPreapprovedToolNames(prepared.tools, ruleset, docActions) const approvedToolsForSession = new Set() workflowModel.approvalHandler = bridge.bind(async (approvalTools) => { @@ -404,6 +429,7 @@ export const node = LayerNode.make({ Provider.node, Plugin.node, Permission.node, + AlphaToolPolicy.node, EventV2Bridge.node, llmClient, RuntimeFlags.node, diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 8754895c087f..8d4876c3775d 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -4,6 +4,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import type { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceState } from "@/effect/instance-state" import { Permission } from "@/permission" +import type { AlphaToolPolicy } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" import { attachToolDisplay, getToolDisplay } from "../tool-display" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "../message-v2" @@ -32,6 +34,7 @@ type PrepareInput = { readonly provider: Provider.Info readonly auth: Auth.Info | undefined readonly plugin: Plugin.Interface + readonly toolPolicy: AlphaToolPolicy.Interface readonly flags: RuntimeFlags.Info readonly isWorkflow: boolean } @@ -146,7 +149,19 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }, ) - const tools = resolveTools(input) + // #1129 / #724 §6:目录闸的第二条轴 —— 策略**文档轴**按当轮 snapshot 判 deny + // (「disabled:目录不广告;ask 照旧广告」)。ruleset 轴的既有过滤在 resolveTools 里原样保留; + // 两轴对 deny 取并集。`host::StructuredOutput` 的 sentinel 豁免在 gate 里(exact canonical)。 + const policyDenied = yield* AlphaToolPolicyGate.snapshotCatalogDenied( + input.toolPolicy, + Object.entries(input.tools).map(([technicalId, item]) => { + const display = getToolDisplay(item) + if (!display) throw new Error(`tool ${technicalId} is missing its source identity`) + return { technicalId, identity: display.identity, authority: display.authority } + }), + Permission.merge(input.agent.permission, input.permission ?? []), + ) + const tools = resolveTools(input, policyDenied) // Codex parity: OpenAI Responses-family providers hardcode `strict: false` // on every function tool so MCP-sourced and dynamic schemas that don't // satisfy OpenAI's structured-outputs constraints still register. @@ -211,7 +226,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre } }) -function resolveTools(input: Pick) { +function resolveTools( + input: Pick, + policyDenied: ReadonlySet, +) { const disabled = Permission.disabled( Object.entries(input.tools).map(([technicalId, tool]) => { const display = getToolDisplay(tool) @@ -220,7 +238,10 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter( + input.tools, + (_, k) => input.user.tools?.[k] !== false && !disabled.has(k) && !policyDenied.has(k), + ) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6a9a7138a452..276e86e7df07 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -33,6 +33,8 @@ import { NamedError } from "@opencode-ai/core/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@opencode-ai/core/shell" @@ -57,7 +59,7 @@ import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { attachToolDisplay } from "./tool-display" -import { canonicalToolIdentity, type ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" +import { type ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -131,6 +133,7 @@ const layer = Layer.effect( const commands = yield* Command.Service const config = yield* Config.Service const permission = yield* Permission.Service + const toolPolicy = yield* AlphaToolPolicy.Service const fsys = yield* FSUtil.Service const mcp = yield* MCP.Service const lsp = yield* LSP.Service @@ -288,19 +291,20 @@ const layer = Layer.effect( return yield* Effect.die(new PermissionV1.DeniedError({ ruleset: taskRuleset })) // #1129 / #724 §6 E6:direct subtask 绕过 SessionTools,上面的 disabled 检查只完成 // deny;identity=ask 必须同样成立。在任何副作用(消息/part 持久化、plugin hook、 - // subsession/Task execute)之前用同一个 Permission 引擎按 exact canonical identity - // 走三态:deny ⇒ 具名拒绝(与上面同型,双保险)、ask ⇒ 挂起等批准(reject/超时即 - // fail-closed,什么都没写盘)、allow ⇒ 静默通过(agent 默认 `*: allow` 底保持现状)。 - yield* permission - .ask({ - permission: canonicalToolIdentity(taskTool.identity), - sessionID, - metadata: { agent: task.agent, description: task.description }, - patterns: ["*"], - always: ["*"], - ruleset: taskRuleset, - }) - .pipe(Effect.orDie) + // subsession/Task execute)之前走与 E1-E4 同一个 gate:ruleset 轴 + 策略**文档轴** + // (调用时重读;task 是 builtin 类,binding 为应用常量)。deny ⇒ 具名拒绝(与上面 + // 同型,双保险)、ask ⇒ 挂起等批准(reject/超时即 fail-closed,什么都没写盘)、 + // allow ⇒ 静默通过(builtin 类默认 enabled,agent 默认 `*: allow` 底保持现状)。 + yield* AlphaToolPolicyGate.gateToolExecution({ + policy: toolPolicy, + permission, + subject: Effect.succeed( + AlphaToolPolicyGate.builtinSubject(taskTool.identity, { kind: "not-asserted" }), + ), + ruleset: taskRuleset, + sessionID, + metadata: { agent: task.agent, description: task.description }, + }).pipe(Effect.orDie) const assistantMessage: SessionV1.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "assistant", @@ -1269,6 +1273,7 @@ const layer = Layer.effect( messages: msgs, promptOps, }).pipe( + Effect.provideService(AlphaToolPolicy.Service, toolPolicy), Effect.provideService(Plugin.Service, plugin), Effect.provideService(Permission.Service, permission), Effect.provideService(ToolRegistry.Service, registry), @@ -1650,6 +1655,7 @@ export const node = LayerNode.make({ Command.node, Config.node, Permission.node, + AlphaToolPolicy.node, FSUtil.node, MCP.node, LSP.node, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index c0c643d8c67e..b4c86fbba37a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -23,7 +23,10 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" -import { canonicalToolIdentity, ToolAliasLedger, type ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" +import { ToolAliasLedger, type ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" +import type { ToolPolicySubject } from "@opencode-ai/schema/alpha-tool-policy" +import { AlphaToolPolicy, mcpBindingDigest } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" import { attachToolDisplay } from "./tool-display" const MCP_RESOURCE_TOOLS = { @@ -54,6 +57,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const plugin = yield* Plugin.Service const permission = yield* Permission.Service const registry = yield* ToolRegistry.Service + const toolPolicy = yield* AlphaToolPolicy.Service const mcp = yield* MCP.Service const truncate = yield* Truncate.Service const flags = yield* RuntimeFlags.Service @@ -61,12 +65,35 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // #1129 / #724 §6(E1/E2/E3)—— 所有来源共用的执行闸,包在公共 register() 上: // identity 三态早于 `tool.execute.before` 钩子与实现本体(闸在 wrapper、钩子在被包的 - // execute 内,先后是结构性的,不靠纪律)。deny ⇒ 具名响亮拒绝、零 hook/零副作用; - // ask ⇒ 走现有 Permission 引擎挂起等批准(once/always 的会话语义在 permission/index.ts, - // #1128);allow ⇒ 不加 identity prompt,工具自身的 read/edit/bash/external_directory 等 - // ability 闸原样留在 execute 内部。ruleset 在每次调用时重新求值(stale/cached 工具对象 - // 拿不到旧宽松态);agent 默认 ruleset 的 `*: allow` 底(agent/agent.ts)保证无 identity - // 规则时行为不变。不放行任何缺 identity 的工具 —— display.identity 是 register 的必填参。 + // execute 内,先后是结构性的,不靠纪律)。两条轴在 alpha-tool-policy-gate 里按 §4 合成: + // ruleset 轴(config/agent/session 的 identity 规则,#1121/#1135)deny 折进 cap、 + // ask/allow 原地生效;**策略文档轴**(#1128 的 resolve:managed/entitlement cap → + // 用户 selector(binding guard)→ 四类默认)在**每次调用时重读** —— Settings/managed + // 在模型拿到旧 catalog 后收紧,缓存的工具对象也执行不了(§6 executor 重读)。 + // deny ⇒ 具名响亮拒绝、零 hook/零副作用;ask ⇒ 同一次 Permission.ask 挂起等批准 + // (once/always 会话语义在 permission/index.ts);allow ⇒ 不加 identity prompt, + // 工具自身的 read/edit/bash/external_directory 等 ability 闸原样留在 execute 内部。 + // 不放行任何缺 identity 的工具 —— display.identity 是 register 的必填参。 + // + // 当前 binding(§5)也在调用时重新派生:mcp = 生效配置 entry 的去秘密 digest + // (rebind 后与记录里的 digest 不等 ⇒ enabled 降回 ask),plugin = 本装载代 digest, + // builtin/host = 应用常量。 + const currentSubject = (display: ToolDisplaySnapshotV1): Effect.Effect => + Effect.gen(function* () { + const identity = display.identity + if (identity.source === "mcp") { + const facts = mcp.bindingFacts ? yield* mcp.bindingFacts(identity.origin) : undefined + return { + identity, + authority: facts?.authority ?? { kind: "not-asserted" as const }, + bindingDigest: facts?.entry ? mcpBindingDigest(identity.origin, facts.entry) : undefined, + } + } + if (identity.source === "plugin") { + return { identity, authority: display.authority, bindingDigest: yield* registry.pluginBinding(identity.origin) } + } + return AlphaToolPolicyGate.builtinSubject(identity, display.authority) + }) const identityGate = (value: AITool, display: ToolDisplaySnapshotV1): AITool => { const execute = value.execute if (!execute) return value @@ -75,17 +102,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { execute: (args, options) => run .promise( - permission - .ask({ - permission: canonicalToolIdentity(display.identity), - sessionID: input.session.id, - metadata: {}, - patterns: ["*"], - always: ["*"], - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }) - .pipe(Effect.orDie), + AlphaToolPolicyGate.gateToolExecution({ + policy: toolPolicy, + permission, + subject: currentSubject(display), + ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + metadata: {}, + }).pipe(Effect.orDie), ) .then(() => execute.call(value, args, options)), } as AITool diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 80782cf42d7d..26530f5b0f8f 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -8,7 +8,9 @@ import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Permission } from "@/permission" import { Plugin } from "@/plugin" -import { canonicalToolIdentity } from "@opencode-ai/schema/tool-identity" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { AlphaToolPolicy, mcpBindingDigest } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" export const CODE_MODE_TOOL = "execute" @@ -135,6 +137,10 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) = const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: { plugin: Plugin.Interface + policy: AlphaToolPolicy.Interface + permission: Permission.Interface + mcp: MCP.Interface + ruleset: PermissionV1.Ruleset entry: CatalogEntry args: Record callID: string @@ -144,9 +150,26 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: // register wrapper,这里是它唯一的 identity 闸。deny/ask 未放行前,插件钩子看不到这次 // 调用,MCP 传输一个字节都不发。主权 kill-switch 钩子仍无条件先于传输执行, // 任何 ask 放行都跳不过它(ext 的 cloud-websearch-kill.test.ts 钉住这半边)。 + // 与 E1-E3 同一 gate:策略文档轴在**每次子调用时重读**(§6 executor 重读),当前 binding + // 也重新派生 —— 子工具全是 MCP,rebind 后旧的 service/tool enabled 回 ask。 const identity = input.entry.tool.identity if (!identity) throw new Error(`MCP tool ${input.entry.key} is missing its source identity`) - yield* input.ctx.ask({ permission: canonicalToolIdentity(identity), metadata: {}, patterns: ["*"], always: ["*"] }) + yield* AlphaToolPolicyGate.gateToolExecution({ + policy: input.policy, + permission: input.permission, + subject: Effect.gen(function* () { + const facts = input.mcp.bindingFacts ? yield* input.mcp.bindingFacts(identity.origin) : undefined + return { + identity, + authority: facts?.authority ?? ({ kind: "not-asserted" } as const), + bindingDigest: facts?.entry ? mcpBindingDigest(identity.origin, facts.entry) : undefined, + } + }), + ruleset: input.ruleset, + sessionID: input.ctx.sessionID, + tool: { messageID: input.ctx.messageID, callID: input.callID }, + metadata: {}, + }).pipe(Effect.orDie) yield* input.plugin.trigger( "tool.execute.before", { tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID }, @@ -200,6 +223,8 @@ export const CodeModeTool = Tool.define( const agents = yield* Agent.Service const sessions = yield* Session.Service const plugin = yield* Plugin.Service + const permission = yield* Permission.Service + const toolPolicy = yield* AlphaToolPolicy.Service const init: Tool.DefWithoutID = { description: DESCRIPTION, @@ -215,7 +240,21 @@ export const CodeModeTool = Tool.define( const agent = yield* agents.get(ctx.agent) const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie) const ruleset = Permission.merge(agent.permission, session.permission ?? []) - const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset) + const rulesetVisible = Permission.visibleTools(yield* mcp.tools(), ruleset) + // #1129 / #724 §6 E4:「disabled child 也不进 child catalog」的第二条轴 —— + // 策略文档轴按当轮 snapshot 判 deny(ask 照旧进目录,由上面的执行闸负责问)。 + const childPolicyDenied = yield* AlphaToolPolicyGate.snapshotCatalogDenied( + toolPolicy, + Object.entries(rulesetVisible).map(([technicalId, item]) => ({ + technicalId, + identity: item.identity!, + authority: item.authority ?? ({ kind: "not-asserted" } as const), + })), + ruleset, + ) + const mcpTools = Object.fromEntries( + Object.entries(rulesetVisible).filter(([technicalId]) => !childPolicyDenied.has(technicalId)), + ) const catalog = [...groupByServer(mcpTools).values()].flat() const calls: CallEntry[] = [] @@ -229,6 +268,10 @@ export const CodeModeTool = Tool.define( childCalls += 1 const result = yield* invokeChildTool({ plugin, + policy: toolPolicy, + permission, + mcp, + ruleset, entry, args: (input ?? {}) as Record, callID: `${ctx.callID ?? entry.key}/${childCalls}`, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 007d6ea645a8..d7f17ebd371d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -55,6 +55,10 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { McpCatalog } from "@/mcp/catalog" import { ToolAliasLedger, type ToolIdentity } from "@opencode-ai/schema/tool-identity" +import { readFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { canonicalJsonDigest } from "@/permission/alpha-tool-policy-store" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel @@ -69,6 +73,8 @@ type State = { builtin: RegisteredTool[] task: TaskDef read: ReadDef + /** #1129(REQ-131 §5):plugin origin → 本次装载代的 binding digest(loader generation)。 */ + pluginBindings: Record } export interface Interface { @@ -81,6 +87,14 @@ export interface Interface { agent: Agent.Info permission?: PermissionV1.Ruleset }) => Effect.Effect + /** + * #1129(REQ-131 §5):某个 plugin origin **当前装载代**的 binding digest。 + * 目录插件 = 源文件内容哈希(重装/更新 ⇒ 下次装载 digest 变 ⇒ 旧的 service/tool enabled + * 记录按 binding-changed 回 ask);hook 插件 = origin spec 本身(它没有更细的安装回执, + * 版本随 origin 变则 identity 整个变)。未知 origin 返回 undefined(fail-closed: + * 依赖 digest 的放宽派生不出来,回 ask)。 + */ + readonly pluginBinding: (origin: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/ToolRegistry") {} @@ -180,6 +194,7 @@ const layer = Layer.effect( } } + const pluginBindings: Record = {} const dirs = yield* config.directories() const matches = dirs.flatMap((dir) => Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), @@ -187,6 +202,12 @@ const layer = Layer.effect( if (matches.length) yield* config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) + // #1129 / #724 §5:binding = 本次装载代的源文件内容(load 之后文件再变,跑的仍是这一代)。 + pluginBindings[namespace] = canonicalJsonDigest({ + kind: "plugin-file", + origin: namespace, + contentDigest: createHash("sha256").update(readFileSync(match)).digest("hex"), + }) // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. // Import it as `file://` so Node on Windows accepts the dynamic import. const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) @@ -198,6 +219,7 @@ const layer = Layer.effect( const plugins = yield* plugin.tools() for (const p of plugins) { + pluginBindings[p.origin] ??= canonicalJsonDigest({ kind: "plugin-hook", origin: p.origin }) for (const [id, def] of Object.entries(p.tools)) { custom.push(fromPlugin(id, p.origin, id, def)) } @@ -258,6 +280,7 @@ const layer = Layer.effect( builtin, task: builtin.find((item) => item.id === tool.task.id)! as TaskDef, read: builtin.find((item) => item.id === tool.read.id)! as ReadDef, + pluginBindings, } }), ) @@ -271,6 +294,12 @@ const layer = Layer.effect( return (yield* all()).map((tool) => tool.id) }) + const pluginBinding: Interface["pluginBinding"] = Effect.fn("ToolRegistry.pluginBinding")(function* ( + origin: string, + ) { + return (yield* InstanceState.get(state)).pluginBindings[origin] + }) + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { const items = (yield* agents.list()).filter((item) => item.mode !== "primary") const filtered = items.filter( @@ -354,7 +383,7 @@ const layer = Layer.effect( return { task: s.task, read: s.read } }) - return Service.of({ ids, all, named, tools }) + return Service.of({ ids, all, named, tools, pluginBinding }) }), ) @@ -440,6 +469,8 @@ export const node = LayerNode.make({ deps: [ Config.node, Plugin.node, + Permission.node, + AlphaToolPolicy.node, Question.node, Todo.node, Agent.node, diff --git a/packages/opencode/test/fixture/alpha-tool-policy.ts b/packages/opencode/test/fixture/alpha-tool-policy.ts new file mode 100644 index 000000000000..9baebd062327 --- /dev/null +++ b/packages/opencode/test/fixture/alpha-tool-policy.ts @@ -0,0 +1,42 @@ +// alpha 自有测试夹具(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// #1129:策略文档轴的 **in-memory 句柄** —— 生产的纯合成核 `resolveToolPolicy`(#1128) +// 套一层可变 records。给没有 Instance 的纯 mock rig(code-mode 系列)用:量的是 +// 「调用方每次都真的问了这个 resolver」;真 store 的读写/quarantine/分区语义由 +// `test/permission/alpha-tool-policy.test.ts` 与 doc-axis 闸(带真 instance)各自钉住。 +// +// records 传**可变数组引用**:重读判据(#724 §6 executor 调用时重读)靠调用后改它。 +import { Effect, Layer } from "effect" +import type { ToolPolicyRecord } from "@opencode-ai/schema/alpha-tool-policy" +import { AlphaToolPolicy, resolveToolPolicy } from "../../src/permission/alpha-tool-policy" + +export function inMemoryToolPolicy(records: ToolPolicyRecord[]): AlphaToolPolicy.Interface { + const managed = { status: "ok", ruleset: [], sources: [] } as const + const partition = { account: "anonymous", workspace: "in-memory" } + const user = () => ({ status: "ok", records: [...records] }) as const + return { + resolve: (subject, caps) => + Effect.sync(() => + resolveToolPolicy({ + subject, + caps: { managed, entitlement: caps?.entitlement, hardDeny: caps?.hardDeny }, + user: user(), + }), + ), + snapshot: () => + Effect.sync(() => ({ + partition, + managed, + user: user(), + load: { status: "ok" as const, doc: { version: 1 as const, partition, records: [...records] } }, + })), + inspect: () => Effect.die(new Error("inMemoryToolPolicy: not wired in this rig")), + setRecord: () => Effect.die(new Error("inMemoryToolPolicy: not wired in this rig")), + removeRecord: () => Effect.die(new Error("inMemoryToolPolicy: not wired in this rig")), + reset: () => Effect.die(new Error("inMemoryToolPolicy: not wired in this rig")), + } +} + +export function inMemoryToolPolicyLayer(records: ToolPolicyRecord[] = []) { + return Layer.succeed(AlphaToolPolicy.Service, AlphaToolPolicy.Service.of(inMemoryToolPolicy(records))) +} diff --git a/packages/opencode/test/permission/alpha-tool-inventory.test.ts b/packages/opencode/test/permission/alpha-tool-inventory.test.ts new file mode 100644 index 000000000000..fcf9d8ba18e2 --- /dev/null +++ b/packages/opencode/test/permission/alpha-tool-inventory.test.ts @@ -0,0 +1,350 @@ +// alpha-code#1129(reopen)—— **dynamic tool policy inventory** 的常驻闸(#724 §5 末条 + +// 设计稿 `2026-08-25-req131-settings-tool-policy` §3 表)。 +// +// inventory 是 #1130 Settings「工具」节的唯一数据源。这里钉四件事: +// ① 从 live registry/materialization 派生:builtin / plugin / host / MCP 四来源都在, +// internal sentinel(StructuredOutput / _noop)不在,identity + authority + 每层 digest +// + effective(9 型 reason)+ 「新发现」+ 无法核验计数逐项在场; +// ② **徽标说真话**:inventory 说 disabled 的工具,生产 executor(`SessionTools.resolve() +// .execute()`)当场拒绝 —— 同一 resolver,两面必须一致; +// ③ binding change:inventory 发放的 digest 正是让 enabled 生效的那一个;rebind 后 +// reason 翻成 binding-changed、digest 换新; +// ④ config ruleset 的 identity deny 折进 cap(`permission-ruleset`)—— Settings 能解释 +// 「为什么这行锁着」,而不是显示 enabled 却执行不了。 +import fs from "node:fs/promises" +import path from "node:path" +import { afterEach, describe, expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Effect, Exit } from "effect" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { AlphaToolPolicy, policyFilePath } from "@/permission/alpha-tool-policy" +import { AlphaToolInventory } from "@/permission/alpha-tool-inventory" +import { Plugin } from "@/plugin" +import type { Provider } from "@/provider/provider" +import { MessageID, SessionID } from "@/session/schema" +import type { Session } from "@/session/session" +import type { SessionProcessor } from "@/session/processor" +import { SessionTools } from "@/session/tools" +import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool/truncate" +import type { TaskPromptOps } from "@/tool/task" +import type { ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" +import { TestConfig } from "../fixture/config" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// ── 手写的期望字面量 ───────────────────────────────────────────────────────── +const ID_MCP_PAID = "mcp:policy:paid_action" +const ID_PLUGIN_PROBE = "plugin:probe:default" +const ID_BUILTIN_WRITE = "builtin::write" +const ALIAS_MCP_PAID = "policy_paid_action" + +function testServer() { + const counts = { call: 0 } + const handle = Effect.acquireRelease( + Effect.promise(async () => { + const listed: MCPToolDef[] = [ + { name: "paid_action", description: "remote paid", inputSchema: { type: "object", properties: {} } }, + ] + const protocol = new Server({ name: "alpha-1129-inventory", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: listed })) + protocol.setRequestHandler(CallToolRequestSchema, () => { + counts.call += 1 + return Promise.resolve({ content: [{ type: "text" as const, text: "SIDE-EFFECT-EXECUTED" }] }) + }) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ port: 0, fetch: (request) => transport.handleRequest(request) }) + return { + url: http.url.toString(), + counts, + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) + return { handle, counts } +} + +const remote = (url: string) => ({ type: "remote" as const, url, oauth: false as const }) + +// 分区隔离:tmpdir instance 的 project.id 在本文件多个用例间相同 ⇒ (anonymous, workspace) +// 策略文件会跨用例泄漏(前一条写的 disabled 咬到后一条)。用生产层自己的 account 注入口 +// (#1128 的测试口)给每条用例独立分区;层其余部分全是生产的。 +let policyAccount = "anonymous" +let configPermission: Record = {} +const freshPolicyAccount = Effect.sync(() => { + policyAccount = `t-${Math.random().toString(36).slice(2)}` + configPermission = {} +}) + +const writeProbePlugin = Effect.fn("alpha1129inv.writeProbePlugin")(function* () { + const test = yield* TestInstance + const dir = path.join(test.directory, ".opencode", "tool") + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "probe.ts"), + [ + "export default {", + " description: 'alpha 1129 inventory probe tool',", + " args: {},", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), + ), + ) +}) + +function testModel(): Provider.Model { + return { + id: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + name: "Test Model", + api: { id: "test-model", url: "http://localhost:1/v1", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100000, output: 10000 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2025-01-01", + variants: {}, + } +} + +const SESSION_ID = SessionID.create() + +type Processor = Pick< + SessionProcessor.Handle, + "message" | "updateToolCall" | "completeToolCall" | "registerToolDisplay" +> + +function testProcessor(): Processor { + return { + message: { id: MessageID.ascending() }, + updateToolCall: () => Effect.void, + completeToolCall: () => Effect.void, + registerToolDisplay: (_technicalId: string, _display: ToolDisplaySnapshotV1) => {}, + } as unknown as Processor +} + +const resolveTools = Effect.fn("alpha1129inv.resolveTools")(function* () { + const agents = yield* Agent.Service + const session: Session.Info = { + id: SESSION_ID, + slug: "alpha-1129-inv", + projectID: ProjectV2.ID.make("alpha-1129-inv"), + directory: ".", + title: "alpha-1129-inv", + version: "0.0.0", + time: { created: 0, updated: 0 }, + permission: [{ permission: "*", pattern: "*", action: "allow" } as PermissionV1.Rule], + } + return yield* SessionTools.resolve({ + agent: yield* agents.defaultInfo(), + model: testModel(), + session, + processor: testProcessor(), + bypassAgentCheck: false, + messages: [], + promptOps: {} as TaskPromptOps, + }) +}) + +const it = testEffect( + LayerNode.compile( + LayerNode.group([ + AlphaToolInventory.node, + AlphaToolPolicy.node, + ToolRegistry.node, + MCP.node, + Agent.node, + Permission.node, + Plugin.node, + Truncate.node, + RuntimeFlags.node, + ]), + [ + [ + Config.node, + TestConfig.layer({ + // I4:inventory 的 ruleset 轴读 `cfg.permission`(生产 `Permission.fromConfig` 原样吃它); + // 「用户写得出这条 config」的可达性已由 725 探针的 R1(真 Config + 真 opencode.json)钉住。 + get: () => Effect.sync(() => ({ permission: configPermission }) as never), + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: false })], + [AlphaToolPolicy.node, AlphaToolPolicy.layer({ account: Effect.sync(() => policyAccount) })], + ], + ), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +const setup = Effect.fn("alpha1129inv.setup")(function* () { + yield* freshPolicyAccount + yield* writeProbePlugin() + const server = testServer() + const running = yield* server.handle + const mcp = yield* MCP.Service + yield* mcp.add("policy", remote(running.url)) + return { counts: server.counts, url: running.url } +}) + +describe("#1129 dynamic tool policy inventory(#724 §5)", () => { + it.instance("I1 从 live registry 派生:四来源在场、sentinel 缺席、digest/新发现/无法核验计数逐项在场", () => + Effect.gen(function* () { + yield* setup() + const inventory = yield* AlphaToolInventory.Service + const listed = yield* inventory.list() + expect(listed.version).toBe(1) + // 「仅当前账户与当前项目」:分区与引擎自己的一致。 + const policy = yield* AlphaToolPolicy.Service + expect(listed.partition).toEqual((yield* policy.inspect()).partition) + expect(listed.user.status).toBe("absent") + expect(listed.managed.status).toBe("ok") + + const tools = listed.services.flatMap((service) => service.tools) + const byCanonical = new Map(tools.map((tool) => [tool.canonical, tool])) + // builtin:默认 enabled,binding = 应用常量。 + const write = byCanonical.get(ID_BUILTIN_WRITE) + expect(write?.effective).toMatchObject({ state: "enabled", reason: { kind: "default", class: "builtin" } }) + expect(write?.bindingDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(write?.newlyDiscovered).toBe(false) + // plugin:默认 ask,新发现,binding 是本装载代 digest。 + const probe = byCanonical.get(ID_PLUGIN_PROBE) + expect(probe?.effective).toMatchObject({ state: "ask", reason: { kind: "default", class: "plugin" } }) + expect(probe?.newlyDiscovered).toBe(true) + expect(probe?.bindingDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + // MCP:默认 ask,新发现,service 行携带写 enabled 用的当前 digest。 + const paid = byCanonical.get(ID_MCP_PAID) + expect(paid?.effective).toMatchObject({ state: "ask", reason: { kind: "default", class: "third-party-mcp" } }) + expect(paid?.newlyDiscovered).toBe(true) + expect(paid?.authority).toEqual({ kind: "not-asserted" }) + const mcpService = listed.services.find((service) => service.source === "mcp" && service.origin === "policy") + expect(mcpService?.class).toBe("third-party-mcp") + expect(mcpService?.bindingDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + // internal sentinel 不进 Settings(§6 窄例外)。 + expect(byCanonical.has("host::StructuredOutput")).toBe(false) + expect(tools.some((tool) => tool.technicalId === "_noop")).toBe(false) + // 无法核验计数:健康现场为 0(owner 裁决 Q1 的暴露口)。 + expect(listed.invalid).toEqual({ count: 0, entries: [] }) + }), + ) + + it.instance("I2 徽标说真话:inventory 说 disabled 的工具,生产 executor 当场拒绝(同一 resolver)", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const policy = yield* AlphaToolPolicy.Service + const inventory = yield* AlphaToolInventory.Service + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }) + const listed = yield* inventory.list() + const paid = listed.services.flatMap((s) => s.tools).find((tool) => tool.canonical === ID_MCP_PAID) + expect(paid?.effective).toMatchObject({ state: "disabled", reason: { kind: "user", level: "tool" } }) + expect(paid?.record).toEqual({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }) + expect(paid?.newlyDiscovered).toBe(false) + // 同一主体、另一面:生产执行入口必须给出同一个结论。 + const tools = yield* resolveTools() + const exit = yield* Effect.promise(() => + tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "i2", messages: [] } as never), + ).pipe(Effect.exit) + expect(Exit.isFailure(exit) || counts.call === 0).toBe(true) + expect(counts.call).toBe(0) + }), + ) + + it.instance("I3 binding change:inventory 发放的 digest 正是让 enabled 生效的那一个;rebind 后回 ask + 新 digest", () => + Effect.gen(function* () { + yield* setup() + const policy = yield* AlphaToolPolicy.Service + const inventory = yield* AlphaToolInventory.Service + const before = yield* inventory.list() + const digest = before.services.find((s) => s.source === "mcp" && s.origin === "policy")?.bindingDigest + expect(digest).toBeDefined() + yield* policy.setRecord({ + selector: { level: "service", source: "mcp", origin: "policy" }, + state: "enabled", + bindingDigest: digest!, + }) + const enabled = yield* inventory.list() + const paidEnabled = enabled.services.flatMap((s) => s.tools).find((tool) => tool.canonical === ID_MCP_PAID) + expect(paidEnabled?.effective).toMatchObject({ state: "enabled", reason: { kind: "user", level: "service" } }) + // rebind:同一配置键换 URL ⇒ digest 换新、enabled 记录失效回 ask(§5)。 + const second = testServer() + const running2 = yield* second.handle + const mcp = yield* MCP.Service + yield* mcp.add("policy", remote(running2.url)) + const after = yield* inventory.list() + const newDigest = after.services.find((s) => s.source === "mcp" && s.origin === "policy")?.bindingDigest + expect(newDigest).toBeDefined() + expect(newDigest).not.toBe(digest) + const paidAfter = after.services.flatMap((s) => s.tools).find((tool) => tool.canonical === ID_MCP_PAID) + expect(paidAfter?.effective).toMatchObject({ state: "ask", reason: { kind: "binding-changed", level: "service" } }) + }), + ) + + it.instance("I4 config ruleset 的 identity deny 折进 cap:Settings 能解释「为什么锁着」", () => + Effect.gen(function* () { + yield* setup() + configPermission = { [ID_MCP_PAID]: "deny" } + const inventory = yield* AlphaToolInventory.Service + const listed = yield* inventory.list() + const paid = listed.services.flatMap((s) => s.tools).find((tool) => tool.canonical === ID_MCP_PAID) + expect(paid?.effective.state).toBe("disabled") + expect(paid?.effective.reason).toEqual({ kind: "cap-hard-deny", sources: ["permission-ruleset"] }) + }), + ) + + it.instance("I5 quarantine 浮出水面:文档损坏 ⇒ user.status=quarantined,工具逐行 disabled(可解释)", () => + Effect.gen(function* () { + yield* setup() + const policy = yield* AlphaToolPolicy.Service + const inventory = yield* AlphaToolInventory.Service + yield* policy.setRecord({ selector: { level: "class", class: "plugin" }, state: "enabled" }) + const { partition } = yield* policy.inspect() + const file = policyFilePath(path.join(Global.Path.data, "alpha-tool-policy"), partition) + yield* Effect.promise(() => fs.writeFile(file, "not a document")) + const listed = yield* inventory.list() + expect(listed.user.status).toBe("quarantined") + const paid = listed.services.flatMap((s) => s.tools).find((tool) => tool.canonical === ID_MCP_PAID) + expect(paid?.effective.state).toBe("disabled") + expect(paid?.effective.reason.kind).toBe("quarantine") + }), + ) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5c4f32679ebc..a8ca843662b9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -584,6 +584,17 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { list: () => Effect.succeed([]), init: () => Effect.void, } as any, + // #1129:prepare 的策略文档轴走 handle;本用例只测 provider options 变换, + // 给一个「文档为空、managed ok」的最小 snapshot(与全新用户一致)。 + toolPolicy: { + snapshot: () => + Effect.succeed({ + partition: { account: "anonymous", workspace: "transform-test" }, + managed: { status: "ok", ruleset: [], sources: [] }, + user: { status: "absent" }, + load: { status: "absent" }, + }), + } as any, flags: { outputTokenMax: 32_000, client: "test" } as any, isWorkflow: false, }), diff --git a/packages/opencode/test/session/alpha-subtask-attachment-policy.test.ts b/packages/opencode/test/session/alpha-subtask-attachment-policy.test.ts index 2b2b6810ca3a..dbd1dbdd0169 100644 --- a/packages/opencode/test/session/alpha-subtask-attachment-policy.test.ts +++ b/packages/opencode/test/session/alpha-subtask-attachment-policy.test.ts @@ -29,6 +29,7 @@ import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" +import { AlphaToolPolicy } from "../../src/permission/alpha-tool-policy" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" @@ -103,6 +104,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), + bindingFacts: () => Effect.succeed(undefined), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), @@ -132,6 +134,7 @@ const promptRoot = LayerNode.group([ AgentSvc.node, Command.node, Permission.node, + AlphaToolPolicy.node, Plugin.node, Config.node, ProviderSvc.node, @@ -311,6 +314,40 @@ it.instance( { config: cfg }, ) +// ── #1129 reopen:策略**文档轴**抵达 E6(真 store 写入,同一 resolver)──────────────── +it.instance( + "E6 文档轴:Settings 写 builtin::task=disabled(真 setRecord)⇒ loop 具名拒绝,零子会话零持久化", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const permission = yield* Permission.Service + const policy = yield* AlphaToolPolicy.Service + // 生产写入口(#1130 Settings 将走的同一条):tool 层 disabled,收紧不需要 digest。 + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_BUILTIN_TASK }, state: "disabled" }) + const chat = yield* sessions.create({ title: "policy disabled" }) + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const exit = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const squashed = Cause.squash(exit.cause) + expect(squashed).toBeInstanceOf(PermissionV1.DeniedError) + // 具名:拒绝信息点名 canonical 与文档轴来源(不是一句裸 deny)。 + expect(String((squashed as Error).message)).toContain(ID_BUILTIN_TASK) + } + expect(yield* sessions.children(chat.id)).toEqual([]) + expect(yield* assistantToolParts(chat.id)).toEqual([]) + // 全程零待批 —— 文档轴 disabled 不是 ask,session grant 结构性接触不到它。 + expect(yield* permission.list()).toEqual([]) + // 「还原后恢复」不在这里补跑第二条 loop:假 provider 会让它进入分钟级重试(实测 180s + // 超时);removeRecord 的恢复语义已由 doc-axis 闸 D4 与 E4 重读用例(真实计数)钉住。 + yield* policy.removeRecord({ level: "tool", canonical: ID_BUILTIN_TASK }) + }), + { config: cfg }, +) + it.instance( "E7 排除锁:builtin::read 与 read ability 全 deny,附件 ingestion 照样完成且全程零待批", () => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8a6cf2ade833..fc22d95c4f31 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -118,6 +118,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { clients: () => Effect.succeed({}), instructions: () => Effect.succeed(instructions), tools: () => Effect.succeed({}), + bindingFacts: () => Effect.succeed(undefined), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/tool/alpha-725-policy-chokepoints.cases.ts b/packages/opencode/test/tool/alpha-725-policy-chokepoints.cases.ts index 0d9249a35cd5..20a3bcfbf64e 100644 --- a/packages/opencode/test/tool/alpha-725-policy-chokepoints.cases.ts +++ b/packages/opencode/test/tool/alpha-725-policy-chokepoints.cases.ts @@ -63,6 +63,9 @@ import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Permission } from "@/permission" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" +import { AlphaToolPolicyGate } from "@/permission/alpha-tool-policy-gate" +import { getToolDisplay } from "@/session/tool-display" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" import { MessageID, SessionID } from "@/session/schema" @@ -280,6 +283,7 @@ const prepareCatalog = Effect.fn("alpha725.prepareCatalog")(function* (ruleset: provider: { id: "test", options: {} } as never, auth: undefined, plugin, + toolPolicy: yield* AlphaToolPolicy.Service, flags, isWorkflow: false, }) @@ -297,6 +301,7 @@ const it = testEffect( MCP.node, Agent.node, Permission.node, + AlphaToolPolicy.node, Plugin.node, Truncate.node, RuntimeFlags.node, @@ -309,6 +314,7 @@ const it = testEffect( }), ], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: false })], + [AlphaToolPolicy.node, AlphaToolPolicy.layer({ account: Effect.sync(() => policyAccount) })], ], ), ) @@ -317,6 +323,15 @@ afterEach(async () => { await disposeAllInstances() }) + +// 分区隔离:tmpdir instance 的 project.id 在本文件多个用例间相同 ⇒ (anonymous, workspace) +// 策略文件会跨用例泄漏(前一条写的 disabled 咬到后一条)。用生产层自己的 account 注入口 +// (#1128 的测试口)给每条用例独立分区;层其余部分全是生产的。 +let policyAccount = "anonymous" +const freshPolicyAccount = Effect.sync(() => { + policyAccount = `t-${Math.random().toString(36).slice(2)}` +}) + /** 「全允许」的底。所有 ability 闸(edit/read/bash/…)都放行 ⇒ 剩下的唯一变量是 identity 策略。*/ const ALLOW_ALL: PermissionV1.Rule = { permission: "*", pattern: "*", action: "allow" } const rulesFor = (identity: string, action: "deny" | "ask"): PermissionV1.Rule[] => [ @@ -324,8 +339,15 @@ const rulesFor = (identity: string, action: "deny" | "ask"): PermissionV1.Rule[] { permission: identity, pattern: "*", action }, ] -/** 装好一套完整现场:真 MCP server + 真插件工具。返回 marker 路径与服务器计数器。*/ +/** 装好一套完整现场:真 MCP server + 真插件工具。返回 marker 路径与服务器计数器。 + * + * #1129 reopen 后的中性底:除 ALLOW_ALL ruleset 外,策略**文档轴**写入 class 层 enabled + * (走生产 `setRecord`;class 层是 broad intent,schema 禁携带 digest)。没有这两条记录, + * 四类默认会让 mcp/plugin 默认 ask —— 那是文档轴自己的判据(在 doc-axis 闸里量), + * 本探针矩阵量的是 **ruleset 轴** 的 deny/ask/once/always 语义,中性底必须把另一条轴放平。 + * 这个状态用户可达:Settings 里把「第三方 MCP」「Plugins」两类总开关设为启用。*/ const setup = Effect.fn("alpha725.setup")(function* () { + yield* freshPolicyAccount const test = yield* TestInstance const marker = path.join(test.directory, "PLUGIN-SIDE-EFFECT.txt") yield* writeProbePlugin(marker) @@ -333,6 +355,9 @@ const setup = Effect.fn("alpha725.setup")(function* () { const running = yield* server.handle const mcp = yield* MCP.Service yield* mcp.add("policy", remote(running.url)) + const policy = yield* AlphaToolPolicy.Service + yield* policy.setRecord({ selector: { level: "class", class: "third-party-mcp" }, state: "enabled" }) + yield* policy.setRecord({ selector: { level: "class", class: "plugin" }, state: "enabled" }) return { marker, counts: server.counts } }) @@ -704,6 +729,22 @@ describe("三态 ask —— 批准前零副作用", () => { ) }) +/** 与 `session/llm.ts` 预批调用点同一派生:snapshot 文档轴动作(subject 不带 digest)。 */ +const docActionsFor = Effect.fn("alpha725.docActionsFor")(function* ( + prepared: Record, + _ruleset: PermissionV1.Ruleset, +) { + const policy = yield* AlphaToolPolicy.Service + return yield* AlphaToolPolicyGate.snapshotDocActions( + policy, + Object.entries(prepared).flatMap(([technicalId, item]) => { + const display = getToolDisplay(item as never) + return display ? [{ technicalId, identity: display.identity, authority: display.authority }] : [] + }), + _ruleset, + ) +}) + // ── E5(DWS workflow):**单元级**,不冒充链路证据 ──────────────────────────────── // `workflowPreapprovedToolNames` 是 `session/llm.ts:162` 在 GitLab workflow 模型上调用的 // 那个导出函数;本机没有 DWS 服务端,整条链路跑不起来 ⇒ 这里只喂**真的** prepared 工具表 @@ -714,7 +755,7 @@ describe("E5 DWS 预批清单(单元级)", () => { Effect.gen(function* () { yield* setup() const prepared = yield* prepareCatalog(rulesFor(ID_MCP_PAID, "ask")) - const names = workflowPreapprovedToolNames(prepared, rulesFor(ID_MCP_PAID, "ask")) + const names = workflowPreapprovedToolNames(prepared, rulesFor(ID_MCP_PAID, "ask"), yield* docActionsFor(prepared, rulesFor(ID_MCP_PAID, "ask"))) expect(Object.keys(prepared)).toContain(ALIAS_MCP_PAID) expect(names).not.toContain(ALIAS_MCP_PAID) expect(names).toContain(ALIAS_MCP_FREE) @@ -725,7 +766,7 @@ describe("E5 DWS 预批清单(单元级)", () => { Effect.gen(function* () { yield* setup() const prepared = yield* prepareCatalog(rulesFor(ID_BUILTIN_WRITE, "ask")) - const names = workflowPreapprovedToolNames(prepared, rulesFor(ID_BUILTIN_WRITE, "ask")) + const names = workflowPreapprovedToolNames(prepared, rulesFor(ID_BUILTIN_WRITE, "ask"), yield* docActionsFor(prepared, rulesFor(ID_BUILTIN_WRITE, "ask"))) expect(Object.keys(prepared)).toContain(ALIAS_BUILTIN_WRITE) expect(names).not.toContain(ALIAS_BUILTIN_WRITE) }), diff --git a/packages/opencode/test/tool/alpha-code-mode-child-policy.test.ts b/packages/opencode/test/tool/alpha-code-mode-child-policy.test.ts index 518c1a5c9464..3bda76e66dad 100644 --- a/packages/opencode/test/tool/alpha-code-mode-child-policy.test.ts +++ b/packages/opencode/test/tool/alpha-code-mode-child-policy.test.ts @@ -31,6 +31,9 @@ import { } from "@modelcontextprotocol/sdk/types.js" import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Cause, Effect, Exit, Layer } from "effect" +import { Permission } from "@/permission" +import type { ToolPolicyRecord } from "@opencode-ai/schema/alpha-tool-policy" +import { inMemoryToolPolicyLayer } from "../fixture/alpha-tool-policy" const SERVER = "fixtures" @@ -106,6 +109,8 @@ async function buildTool(input: { probe: Probe ask: (permission: string) => Effect.Effect ruleset?: PermissionV1.Rule[] + /** 文档轴用户记录(可变引用 —— 重读判据靠调用后改它)。缺省为空 ⇒ 四类默认。 */ + records?: ToolPolicyRecord[] }) { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) @@ -132,7 +137,18 @@ async function buildTool(input: { } const ruleset = input.ruleset ?? [ALLOW_ALL] + const records = input.records ?? [] const layer = Layer.mergeAll( + // #1129:E4 的 identity 闸经 gateToolExecution 走 **Permission.Service**(不再经 ctx.ask); + // ask 探头随之移到这里 —— 事件名与断言不变。 + Layer.mock(Permission.Service, { + ask: ((req: { permission: string }) => + Effect.suspend(() => { + input.probe.events.push(`ask:${req.permission}`) + return input.ask(req.permission) + })) as Permission.Interface["ask"], + }), + inMemoryToolPolicyLayer(records), Layer.mock(Plugin.Service, { trigger: ((name: unknown, hookInput: unknown, output: unknown) => { const tool = (hookInput as { tool?: string })?.tool @@ -148,6 +164,7 @@ async function buildTool(input: { Layer.mock(MCP.Service, { tools: () => Effect.succeed(mcpTools), clients: () => Effect.succeed({ [SERVER]: {} as any }), + bindingFacts: () => Effect.succeed(undefined), }), ) @@ -238,4 +255,60 @@ describe("#1129 E4:code-mode child 的 identity 闸在 hook 之前、传输之 expect(p.transport["add"]).toBe(1) expect(p.events).toContain(`ask:${ID_CHILD_ADD}`) }) + + // ── #1129 reopen:策略**文档轴**抵达 E4(同一 resolver,不是 ruleset 轴的复读)──────── + test("文档轴 tool 层 disabled ⇒ child 不进 catalog、held 调用零 ask/零 hook/零传输", async () => { + const p = probe() + const built = await buildTool({ + probe: p, + // 文档轴 deny 走目录闸 + gate 的具名拒绝,不该走到 Permission.ask;走到就当场炸。 + ask: (permission) => + permission === ID_CHILD_GET_TEXT + ? (Effect.die(new Error(`unexpected ask for policy-disabled ${permission}`)) as Effect.Effect) + : Effect.void, + records: [{ selector: { level: "tool", canonical: ID_CHILD_GET_TEXT }, state: "disabled" }], + }) + const error = await runFailed(built, "return await tools.fixtures.get_text({ name: 'world' })") + expect(error.message).not.toContain("identity policy") + expect(p.transport["get_text"] ?? 0).toBe(0) + expect(p.events.filter((event) => event.startsWith("hook:tool.execute.before"))).toEqual([]) + // 兄弟工具照常(文档轴默认 ask ⇒ mock 放行)。 + const out = await Effect.runPromise( + built.tool.execute({ code: "return await tools.fixtures.add({ a: 1, b: 2 })" }, built.ctx), + ) + expect(out.output).toBe("SIDE-EFFECT-EXECUTED") + expect(p.transport["add"]).toBe(1) + }) + + test("executor 调用时重读:child catalog 发出后收紧文档轴 ⇒ 同一 held 闭包的下一次调用当场 deny", async () => { + // code-mode 每次 execute 重建子目录 ⇒ 「held」必须钉在**同一次 execute 内**捕获的 + // callTool 闭包上:程序连调两次,mock 在放行第一问的同时把文档轴收紧成 disabled —— + // 目录与闭包都是收紧**前**的,第二次调用只有「调用时重读」能拦住。 + const p = probe() + const records: ToolPolicyRecord[] = [] + let armed = true + const built = await buildTool({ + probe: p, + ask: () => { + if (armed) { + armed = false + records.push({ selector: { level: "tool", canonical: ID_CHILD_GET_TEXT }, state: "disabled" }) + } + return Effect.void + }, + records, + }) + const error = await runFailed( + built, + "await tools.fixtures.get_text({ name: 'a' }); return await tools.fixtures.get_text({ name: 'b' })", + ) + // 第一次:默认 ask ⇒ 放行 ⇒ 恰一次传输;第二次:重读到 disabled ⇒ 具名拒绝,传输不再增长。 + expect(String(error.message)).toContain(ID_CHILD_GET_TEXT) + expect(p.transport["get_text"]).toBe(1) + expect(p.events.filter((event) => event === `ask:${ID_CHILD_GET_TEXT}`)).toHaveLength(1) + // 还原 ⇒ 恢复可调(证明上面不是把 rig 弄坏了)。 + records.pop() + await Effect.runPromise(built.tool.execute({ code: "return await tools.fixtures.get_text({ name: 'w2' })" }, built.ctx)) + expect(p.transport["get_text"]).toBe(2) + }) }) diff --git a/packages/opencode/test/tool/alpha-mcp-websearch-gate.test.ts b/packages/opencode/test/tool/alpha-mcp-websearch-gate.test.ts index c6d620ff4439..27163cfc7d94 100644 --- a/packages/opencode/test/tool/alpha-mcp-websearch-gate.test.ts +++ b/packages/opencode/test/tool/alpha-mcp-websearch-gate.test.ts @@ -31,7 +31,9 @@ import { Cause, Effect, Exit, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { MCP } from "../../src/mcp/index" import { McpCatalog } from "../../src/mcp/catalog" +import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" +import { inMemoryToolPolicyLayer } from "../fixture/alpha-tool-policy" import { Session } from "../../src/session/session" import { CodeModeTool } from "../../src/tool/code-mode" import { Tool } from "../../src/tool/tool" @@ -147,6 +149,12 @@ const agentStub = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", mode: "all", permission: [], options: {} } as never), }) const sessionStub = Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as never) }) +// #1129:code-mode child 的 identity 闸经 Permission 引擎;本文件量的是主权 kill-switch +// (hook 层)对传输的拦截 —— identity ask 一律自动放行,放行之后 kill-switch 仍必须拦得住。 +const permissionAutoApprove = Layer.mock(Permission.Service, { + ask: (() => Effect.void) as Permission.Interface["ask"], +}) +const codeModePolicyStubs = Layer.mergeAll(permissionAutoApprove, inMemoryToolPolicyLayer()) const toolContext = (over?: Partial): Tool.Context => ({ sessionID: "ses_mcp_gate" as never, @@ -252,7 +260,7 @@ describe("#223 R5:用户自带的 remote MCP web search 同受主权判决(真 const error = Exit.isFailure(exit) ? (Cause.squash(exit.cause) as Error) : new Error("unreachable") expect(error.message).toContain("do not retry") expect(deniedCodeMode.calls).toEqual([]) - }).pipe(Effect.provide(Layer.mergeAll(truncateStub, agentStub, sessionStub))), + }).pipe(Effect.provide(Layer.mergeAll(truncateStub, agentStub, sessionStub, codeModePolicyStubs))), { config: configWith(() => deniedCodeMode) }, 30_000, ) @@ -270,7 +278,7 @@ describe("#223 R5:用户自带的 remote MCP web search 同受主权判决(真 expect(result.output).toContain("search results") expect(allowedCodeMode.calls).toEqual([EXA_TOOL]) - }).pipe(Effect.provide(Layer.mergeAll(truncateStub, agentStub, sessionStub))), + }).pipe(Effect.provide(Layer.mergeAll(truncateStub, agentStub, sessionStub, codeModePolicyStubs))), { config: configWith(() => allowedCodeMode) }, 30_000, ) diff --git a/packages/opencode/test/tool/alpha-session-tools-alias-lock.test.ts b/packages/opencode/test/tool/alpha-session-tools-alias-lock.test.ts index cfd9fc91335a..45961997fd27 100644 --- a/packages/opencode/test/tool/alpha-session-tools-alias-lock.test.ts +++ b/packages/opencode/test/tool/alpha-session-tools-alias-lock.test.ts @@ -64,6 +64,7 @@ import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Permission } from "@/permission" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" import { MessageID, SessionID } from "@/session/schema" @@ -259,6 +260,7 @@ const it = testEffect( MCP.node, Agent.node, Permission.node, + AlphaToolPolicy.node, Plugin.node, Truncate.node, RuntimeFlags.node, diff --git a/packages/opencode/test/tool/alpha-tool-identity.test.ts b/packages/opencode/test/tool/alpha-tool-identity.test.ts index 1f71fc1435eb..4b3eb5e64c4d 100644 --- a/packages/opencode/test/tool/alpha-tool-identity.test.ts +++ b/packages/opencode/test/tool/alpha-tool-identity.test.ts @@ -175,11 +175,20 @@ describe("stable tool identity", () => { authority: { kind: "not-asserted" }, }), } + // #1129:第三参是策略文档轴动作(必传);本用例隔离 ruleset 轴 ⇒ 文档轴给 allow。 + const docAllow = new Map([["visible_alias", "allow" as const]]) expect( - workflowPreapprovedToolNames(tools, [{ permission: "mcp:server:remote_name", pattern: "*", action: "ask" }]), + workflowPreapprovedToolNames(tools, [{ permission: "mcp:server:remote_name", pattern: "*", action: "ask" }], docAllow), + ).toEqual([]) + expect( + workflowPreapprovedToolNames(tools, [{ permission: "visible_alias", pattern: "*", action: "ask" }], docAllow), + ).toEqual(["visible_alias"]) + // 文档轴 fail-closed:动作缺席 = 不预批(与 ask/deny 同待遇)。 + expect( + workflowPreapprovedToolNames(tools, [], new Map()), + ).toEqual([]) + expect( + workflowPreapprovedToolNames(tools, [], new Map([["visible_alias", "ask" as const]])), ).toEqual([]) - expect(workflowPreapprovedToolNames(tools, [{ permission: "visible_alias", pattern: "*", action: "ask" }])).toEqual( - ["visible_alias"], - ) }) }) diff --git a/packages/opencode/test/tool/alpha-tool-policy-doc-axis-gate.test.ts b/packages/opencode/test/tool/alpha-tool-policy-doc-axis-gate.test.ts new file mode 100644 index 000000000000..17f241c89d61 --- /dev/null +++ b/packages/opencode/test/tool/alpha-tool-policy-doc-axis-gate.test.ts @@ -0,0 +1,495 @@ +// alpha-code#1129(reopen)—— 策略**文档轴**抵达目录与执行咽喉的常驻闸(#724 §2/§4/§5/§6)。 +// +// 与 `alpha-tool-policy-execution-gate.test.ts` 的分工:那份量 **ruleset 轴**(config/session +// 的 identity 规则)在 identityGate 上的语义;本文件量 **文档轴** —— 四类默认、用户 selector +// (真 store,生产 `setRecord`/`removeRecord`/`reset` 写入)、binding guard、quarantine —— +// 以及 §6 那句最容易做假的话:「executor 必须在调用时重读」。 +// +// 判据纪律(同 #725/#1135 口径): +// ① 只驱动生产入口:`SessionTools.resolve()` 返回对象的 `.execute()` 与 +// `LLMRequestPrep.prepare()` 返回的 `tools`; +// ② 期望字面量手写(canonical 串不从 canonicalToolIdentity() 导出); +// ③ 副作用用真实计数:真 MCP server 的 `tools/call`、真插件 marker、真 builtin 落盘; +// ④ 重读判据是**变异式**的:目录发出、工具对象已在手里之后才改策略 —— 一个「executor +// 用目录快照」的错误实现在 ①-③ 下全绿,只有 ④ 能翻它(断言的粒度不能比缺陷粗一格)。 +import fs from "node:fs/promises" +import path from "node:path" +import { afterEach, describe, expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Effect, Exit, Cause } from "effect" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { AlphaToolPolicy, policyFilePath } from "@/permission/alpha-tool-policy" +import { AlphaToolInventory } from "@/permission/alpha-tool-inventory" +import { Plugin } from "@/plugin" +import type { Provider } from "@/provider/provider" +import { MessageID, SessionID } from "@/session/schema" +import type { Session } from "@/session/session" +import type { SessionProcessor } from "@/session/processor" +import { SessionTools } from "@/session/tools" +import { LLMRequestPrep } from "@/session/llm/request" +import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool/truncate" +import type { TaskPromptOps } from "@/tool/task" +import type { ToolDisplaySnapshotV1 } from "@opencode-ai/schema/tool-identity" +import { TestConfig } from "../fixture/config" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// ── 手写的期望字面量(纪律②)──────────────────────────────────────────────────── +const ID_MCP_PAID = "mcp:policy:paid_action" +const ALIAS_MCP_PAID = "policy_paid_action" +const ALIAS_PLUGIN_PROBE = "probe" +const ALIAS_BUILTIN_WRITE = "write" +const ALIAS_HOST_LIST_RESOURCES = "list_mcp_resources" + +function testServer() { + const counts = { call: 0, listResources: 0 } + const handle = Effect.acquireRelease( + Effect.promise(async () => { + const listed: MCPToolDef[] = [ + { name: "paid_action", description: "remote paid", inputSchema: { type: "object", properties: {} } }, + ] + const protocol = new Server( + { name: "alpha-1129-doc-axis", version: "1.0.0" }, + { capabilities: { tools: {}, resources: {} } }, + ) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: listed })) + protocol.setRequestHandler(CallToolRequestSchema, () => { + counts.call += 1 + return Promise.resolve({ content: [{ type: "text" as const, text: "SIDE-EFFECT-EXECUTED" }] }) + }) + protocol.setRequestHandler(ListResourcesRequestSchema, () => { + counts.listResources += 1 + return Promise.resolve({ resources: [{ uri: "mem://one", name: "one", mimeType: "text/plain" }] }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, () => + Promise.resolve({ resourceTemplates: [] }), + ) + protocol.setRequestHandler(ReadResourceRequestSchema, () => + Promise.resolve({ contents: [{ uri: "mem://one", mimeType: "text/plain", text: "resource-body" }] }), + ) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ port: 0, fetch: (request) => transport.handleRequest(request) }) + return { + url: http.url.toString(), + counts, + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) + return { handle, counts } +} + +const remote = (url: string) => ({ type: "remote" as const, url, oauth: false as const }) + +const writeProbePlugin = Effect.fn("alpha1129doc.writeProbePlugin")(function* (marker: string) { + const test = yield* TestInstance + const dir = path.join(test.directory, ".opencode", "tool") + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "probe.ts"), + [ + "import { writeFileSync } from 'node:fs'", + "export default {", + " description: 'alpha 1129 doc-axis probe tool',", + " args: {},", + ` execute: async () => { writeFileSync(${JSON.stringify(marker)}, 'SIDE-EFFECT-EXECUTED'); return 'ok' },`, + "}", + "", + ].join("\n"), + ), + ) +}) + +function testModel(): Provider.Model { + return { + id: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + name: "Test Model", + api: { id: "test-model", url: "http://localhost:1/v1", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100000, output: 10000 }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2025-01-01", + variants: {}, + } +} + +const SESSION_ID = SessionID.create() + +function testSession(ruleset: PermissionV1.Ruleset): Session.Info { + return { + id: SESSION_ID, + slug: "alpha-1129-doc", + projectID: ProjectV2.ID.make("alpha-1129-doc"), + directory: ".", + title: "alpha-1129-doc", + version: "0.0.0", + time: { created: 0, updated: 0 }, + permission: ruleset as PermissionV1.Rule[], + } +} + +type Processor = Pick< + SessionProcessor.Handle, + "message" | "updateToolCall" | "completeToolCall" | "registerToolDisplay" +> + +function testProcessor(): Processor { + return { + message: { id: MessageID.ascending() }, + updateToolCall: () => Effect.void, + completeToolCall: () => Effect.void, + registerToolDisplay: (_technicalId: string, _display: ToolDisplaySnapshotV1) => {}, + } as unknown as Processor +} + +const resolveTools = Effect.fn("alpha1129doc.resolveTools")(function* (ruleset: PermissionV1.Ruleset) { + const agents = yield* Agent.Service + return yield* SessionTools.resolve({ + agent: yield* agents.defaultInfo(), + model: testModel(), + session: testSession(ruleset), + processor: testProcessor(), + bypassAgentCheck: false, + messages: [], + promptOps: {} as TaskPromptOps, + }) +}) + +/** 咽喉 A:真的 `LLMRequestPrep.prepare` 返回的 tools 键集(目录)。 */ +const prepareCatalogKeys = Effect.fn("alpha1129doc.prepareCatalogKeys")(function* (ruleset: PermissionV1.Ruleset) { + const agents = yield* Agent.Service + const plugin = yield* Plugin.Service + const flags = yield* RuntimeFlags.Service + const tools = yield* resolveTools(ruleset) + const prepared = yield* LLMRequestPrep.prepare({ + user: { + id: "msg_alpha1129doc", + role: "user", + sessionID: SESSION_ID, + model: { providerID: "test", modelID: "test-model" }, + time: { created: 0 }, + } as never, + sessionID: SESSION_ID, + model: testModel(), + agent: yield* agents.defaultInfo(), + permission: ruleset as PermissionV1.Rule[], + system: [], + messages: [], + tools, + provider: { id: "test", options: {} } as never, + auth: undefined, + plugin, + toolPolicy: yield* AlphaToolPolicy.Service, + flags, + isWorkflow: false, + }) + return Object.keys(prepared.tools) +}) + +const it = testEffect( + LayerNode.compile( + LayerNode.group([ + ToolRegistry.node, + MCP.node, + Agent.node, + Permission.node, + AlphaToolPolicy.node, + AlphaToolInventory.node, + Plugin.node, + Truncate.node, + RuntimeFlags.node, + ]), + [ + [ + Config.node, + TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: false })], + [AlphaToolPolicy.node, AlphaToolPolicy.layer({ account: Effect.sync(() => policyAccount) })], + ], + ), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +const ALLOW_ALL: PermissionV1.Rule = { permission: "*", pattern: "*", action: "allow" } + +// 分区隔离:tmpdir instance 的 project.id 在本文件多个用例间相同 ⇒ (anonymous, workspace) +// 策略文件会跨用例泄漏(前一条写的 disabled 咬到后一条)。用生产层自己的 account 注入口 +// (#1128 的测试口)给每条用例独立分区;层其余部分全是生产的。 +let policyAccount = "anonymous" +const freshPolicyAccount = Effect.sync(() => { + policyAccount = `t-${Math.random().toString(36).slice(2)}` +}) + +const setup = Effect.fn("alpha1129doc.setup")(function* () { + yield* freshPolicyAccount + const test = yield* TestInstance + const marker = path.join(test.directory, "PLUGIN-SIDE-EFFECT.txt") + yield* writeProbePlugin(marker) + const server = testServer() + const running = yield* server.handle + const mcp = yield* MCP.Service + yield* mcp.add("policy", remote(running.url)) + return { marker, counts: server.counts, url: running.url } +}) + +const markerExists = (marker: string) => Effect.promise(() => Bun.file(marker).exists()) + +function squashed(exit: Exit.Exit) { + if (!Exit.isFailure(exit)) return undefined + return Cause.squash(exit.cause) +} + +function launch(tool: { execute?: (...args: never[]) => unknown }, callID: string, args: unknown = {}) { + let outcome: { ok: true } | { ok: false; error: unknown } | undefined + const promise = Promise.resolve(tool.execute!(args as never, { toolCallId: callID, messages: [] } as never)).then( + () => { + outcome = { ok: true } + }, + (error) => { + outcome = { ok: false, error } + }, + ) + return { promise, settled: () => outcome } +} + +const awaitPending = Effect.fn("alpha1129doc.awaitPending")(function* (rounds = 60) { + const permission = yield* Permission.Service + for (let i = 0; i < rounds; i += 1) { + const pending = yield* permission.list() + if (pending.length > 0) return pending + yield* Effect.sleep("20 millis") + } + return yield* permission.list() +}) + +describe("#1129 文档轴闸(四类默认 / 用户 selector / 重读 / binding guard / quarantine)", () => { + it.instance("D1 四类默认抵达 executor:MCP 默认 ask —— 目录照旧广告,执行挂起等批准,批准前零 tools/call", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const permission = yield* Permission.Service + // 目录:默认 ask 不改变广告(§6:「ask 仍广告;disabled 才移除」)。 + expect(yield* prepareCatalogKeys([ALLOW_ALL])).toContain(ALIAS_MCP_PAID) + const tools = yield* resolveTools([ALLOW_ALL]) + const run = launch(tools[ALIAS_MCP_PAID]!, "d1") + const pending = yield* awaitPending() + expect(pending.length).toBe(1) + expect(pending[0]!.permission).toBe(ID_MCP_PAID) + expect(counts.call).toBe(0) + yield* permission.reply({ requestID: pending[0]!.id, reply: "reject" }) + yield* Effect.promise(() => run.promise) + expect(run.settled()?.ok).toBe(false) + expect(counts.call).toBe(0) + }), + ) + + it.instance("D2 本地类默认 enabled:builtin 与 host 工具零打扰执行(文档轴不加 identity prompt)", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const permission = yield* Permission.Service + const test = yield* TestInstance + const target = path.join(test.directory, "BUILTIN-D2.txt") + const tools = yield* resolveTools([ALLOW_ALL]) + yield* Effect.promise(() => + tools[ALIAS_BUILTIN_WRITE]!.execute!( + { filePath: target, content: "SIDE-EFFECT-EXECUTED" }, + { toolCallId: "d2a", messages: [] } as never, + ), + ) + yield* Effect.promise(() => + tools[ALIAS_HOST_LIST_RESOURCES]!.execute!({}, { toolCallId: "d2b", messages: [] } as never), + ) + expect(yield* markerExists(target)).toBe(true) + expect(counts.listResources).toBe(1) + expect((yield* permission.list()).length).toBe(0) + }), + ) + + it.instance("D3 用户 tool 层 disabled(生产 setRecord)⇒ 目录只删它,执行具名拒绝、零传输、零待批", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const permission = yield* Permission.Service + const policy = yield* AlphaToolPolicy.Service + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }) + const keys = yield* prepareCatalogKeys([ALLOW_ALL]) + expect(keys).not.toContain(ALIAS_MCP_PAID) + expect(keys).toContain(ALIAS_PLUGIN_PROBE) + expect(keys).toContain(ALIAS_BUILTIN_WRITE) + // stale/direct call:目录不广告之外,执行闸还要自己响亮拒绝(双闸,§6)。 + const tools = yield* resolveTools([ALLOW_ALL]) + const exit = yield* Effect.promise(() => + tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d3", messages: [] } as never), + ).pipe(Effect.exit) + const error = squashed(exit) + expect(String((error as Error)?.message ?? error)).toContain(ID_MCP_PAID) + expect(String((error as Error)?.message ?? error)).toContain("alpha-tool-policy") + expect(counts.call).toBe(0) + expect((yield* permission.list()).length).toBe(0) + }), + ) + + it.instance("D4 executor 调用时重读:目录发出、对象在手,再收紧 ⇒ 同一对象当场 deny;还原 ⇒ 恢复", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const policy = yield* AlphaToolPolicy.Service + yield* policy.setRecord({ selector: { level: "class", class: "third-party-mcp" }, state: "enabled" }) + // 目录发出 + 工具对象已在手里(这就是「模型拿到旧 catalog」的那个时刻)。 + expect(yield* prepareCatalogKeys([ALLOW_ALL])).toContain(ALIAS_MCP_PAID) + const tools = yield* resolveTools([ALLOW_ALL]) + yield* Effect.promise(() => tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d4a", messages: [] } as never)) + expect(counts.call).toBe(1) + // Settings 收紧 —— 不重新 resolve,不重发目录。 + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }) + const exit = yield* Effect.promise(() => + tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d4b", messages: [] } as never), + ).pipe(Effect.exit) + expect(squashed(exit)).toBeDefined() + expect(counts.call).toBe(1) // 一个字节都没多发 + // 还原 ⇒ 同一个 held 对象恢复可用(证明重读的是当前文档,不是对象坏了)。 + yield* policy.removeRecord({ level: "tool", canonical: ID_MCP_PAID }) + yield* Effect.promise(() => tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d4c", messages: [] } as never)) + expect(counts.call).toBe(2) + }), + ) + + it.instance("D5 binding guard:service 层 enabled 绑定 inventory 给的 digest;rebind 后同一对象回到 ask", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const permission = yield* Permission.Service + const policy = yield* AlphaToolPolicy.Service + const inventory = yield* AlphaToolInventory.Service + // enabled 写入必须携带 inventory 发放的当前 digest(设计稿 §4;schema 强制)。 + const listed = yield* inventory.list() + const service = listed.services.find((item) => item.source === "mcp" && item.origin === "policy") + expect(service?.bindingDigest).toBeDefined() + yield* policy.setRecord({ + selector: { level: "service", source: "mcp", origin: "policy" }, + state: "enabled", + bindingDigest: service!.bindingDigest!, + }) + const tools = yield* resolveTools([ALLOW_ALL]) + yield* Effect.promise(() => tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d5a", messages: [] } as never)) + expect(counts.call).toBe(1) + expect((yield* permission.list()).length).toBe(0) + // rebind:同一配置键换 URL(§5 的原型场景)。held 对象的传输还指着旧 server —— + // 只有调用时重新派生当前 binding 才拦得住。 + const second = testServer() + const running2 = yield* second.handle + const mcp = yield* MCP.Service + yield* mcp.add("policy", remote(running2.url)) + const run = launch(tools[ALIAS_MCP_PAID]!, "d5b") + const pending = yield* awaitPending() + expect(pending.length).toBe(1) + expect(pending[0]!.permission).toBe(ID_MCP_PAID) + expect(counts.call).toBe(1) // 旧 server 零新调用 + expect(second.counts.call).toBe(0) // 新 server 也一个都没吃到 + yield* permission.reply({ requestID: pending[0]!.id, reply: "reject" }) + yield* Effect.promise(() => run.promise) + }), + ) + + it.instance("D6 quarantine:策略文件损坏 ⇒ 用户可配置工具全体 disabled;生产 reset 恢复默认", () => + Effect.gen(function* () { + const { counts, marker } = yield* setup() + const policy = yield* AlphaToolPolicy.Service + // 先经生产写入口落一份合法文档,再把它写坏 —— 「损坏」发生在真实文件上。 + yield* policy.setRecord({ selector: { level: "class", class: "third-party-mcp" }, state: "enabled" }) + const { partition } = yield* policy.inspect() + const file = policyFilePath(path.join(Global.Path.data, "alpha-tool-policy"), partition) + yield* Effect.promise(() => fs.writeFile(file, "{ this is not a policy document")) + const tools = yield* resolveTools([ALLOW_ALL]) + // MCP:quarantine ⇒ disabled(不是 ask)。 + const exitMcp = yield* Effect.promise(() => + tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d6a", messages: [] } as never), + ).pipe(Effect.exit) + expect(String((squashed(exitMcp) as Error)?.message)).toContain("quarantine") + expect(counts.call).toBe(0) + // plugin 同样(所有用户可配置工具;§5:不得静默忽略一条可能原本是 deny 的坏记录)。 + const exitPlugin = yield* Effect.promise(() => + tools[ALIAS_PLUGIN_PROBE]!.execute!({}, { toolCallId: "d6b", messages: [] } as never), + ).pipe(Effect.exit) + expect(squashed(exitPlugin)).toBeDefined() + expect(yield* markerExists(marker)).toBe(false) + // 生产恢复入口:reset 挪走坏文件(留备份)⇒ 回到批准默认(MCP 默认 ask,而不是照跑)。 + const { backup } = yield* policy.reset() + expect(backup).toBeDefined() + const permission = yield* Permission.Service + const run = launch(tools[ALIAS_MCP_PAID]!, "d6c") + const pending = yield* awaitPending() + expect(pending.length).toBe(1) + yield* permission.reply({ requestID: pending[0]!.id, reply: "reject" }) + yield* Effect.promise(() => run.promise) + expect(counts.call).toBe(0) + }), + ) + + it.instance("D7 用户 disabled 不可被 session grant 撬开:先 always 放行,再 class 层禁用 ⇒ 同一对象被拒", () => + Effect.gen(function* () { + const { counts } = yield* setup() + const permission = yield* Permission.Service + const policy = yield* AlphaToolPolicy.Service + const tools = yield* resolveTools([ALLOW_ALL]) + // ① 默认 ask ⇒ 用户点「总是允许」(grant 落在本 session + canonical 上)。 + const first = launch(tools[ALIAS_MCP_PAID]!, "d7a") + const pending = yield* awaitPending() + yield* permission.reply({ requestID: pending[0]!.id, reply: "always" }) + yield* Effect.promise(() => first.promise) + expect(counts.call).toBe(1) + // ② Settings 把整类禁用。grant 只能 discharge ask,压不过任何 deny(§4.4)。 + yield* policy.setRecord({ selector: { level: "class", class: "third-party-mcp" }, state: "disabled" }) + const exit = yield* Effect.promise(() => + tools[ALIAS_MCP_PAID]!.execute!({}, { toolCallId: "d7b", messages: [] } as never), + ).pipe(Effect.exit) + expect(squashed(exit)).toBeDefined() + expect(counts.call).toBe(1) + expect((yield* permission.list()).length).toBe(0) // deny 不是 ask,没有第二次弹窗 + }), + ) +}) diff --git a/packages/opencode/test/tool/alpha-tool-policy-execution-gate.test.ts b/packages/opencode/test/tool/alpha-tool-policy-execution-gate.test.ts index c2ee8903c479..91cf341121ae 100644 --- a/packages/opencode/test/tool/alpha-tool-policy-execution-gate.test.ts +++ b/packages/opencode/test/tool/alpha-tool-policy-execution-gate.test.ts @@ -37,6 +37,7 @@ import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Permission } from "@/permission" +import { AlphaToolPolicy } from "@/permission/alpha-tool-policy" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" import { MessageID, SessionID } from "@/session/schema" @@ -208,6 +209,7 @@ const it = testEffect( MCP.node, Agent.node, Permission.node, + AlphaToolPolicy.node, Plugin.node, Truncate.node, RuntimeFlags.node, @@ -220,6 +222,7 @@ const it = testEffect( }), ], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: false })], + [AlphaToolPolicy.node, AlphaToolPolicy.layer({ account: Effect.sync(() => policyAccount) })], ], ), ) @@ -229,12 +232,24 @@ afterEach(async () => { }) const ALLOW_ALL: PermissionV1.Rule = { permission: "*", pattern: "*", action: "allow" } + +// 分区隔离:tmpdir instance 的 project.id 在本文件多个用例间相同 ⇒ (anonymous, workspace) +// 策略文件会跨用例泄漏(前一条写的 disabled 咬到后一条)。用生产层自己的 account 注入口 +// (#1128 的测试口)给每条用例独立分区;层其余部分全是生产的。 +let policyAccount = "anonymous" +const freshPolicyAccount = Effect.sync(() => { + policyAccount = `t-${Math.random().toString(36).slice(2)}` +}) const rulesFor = (identity: string, action: "deny" | "ask"): PermissionV1.Rule[] => [ ALLOW_ALL, { permission: identity, pattern: "*", action }, ] +// #1129 reopen 后的中性底:文档轴写 class 层 enabled(生产 `setRecord`;用户可达 —— +// Settings 把「第三方 MCP」「Plugins」总开关设为启用)。本文件量 ruleset 轴语义, +// 文档轴自己的判据在 alpha-tool-policy-doc-axis-gate.test.ts。 const setup = Effect.fn("alpha1129.setup")(function* () { + yield* freshPolicyAccount const test = yield* TestInstance const marker = path.join(test.directory, "PLUGIN-SIDE-EFFECT.txt") yield* writeProbePlugin(marker) @@ -242,6 +257,9 @@ const setup = Effect.fn("alpha1129.setup")(function* () { const running = yield* server.handle const mcp = yield* MCP.Service yield* mcp.add("policy", remote(running.url)) + const policy = yield* AlphaToolPolicy.Service + yield* policy.setRecord({ selector: { level: "class", class: "third-party-mcp" }, state: "enabled" }) + yield* policy.setRecord({ selector: { level: "class", class: "plugin" }, state: "enabled" }) return { marker, counts: server.counts } }) @@ -434,6 +452,7 @@ describe("#1129 internal sentinel(#724 §6:窄例外,不扩张)", () => { provider: { id: "github-copilot", options: {} } as never, auth: undefined, plugin, + toolPolicy: yield* AlphaToolPolicy.Service, flags, isWorkflow: false, }) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 5e34c8960532..fd59da66d0a1 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -1,6 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test" import { CodeModeTool, describeCatalog } from "@/tool/code-mode" import { McpCatalog } from "@/mcp/catalog" +import { Permission } from "@/permission" +import { inMemoryToolPolicyLayer } from "../fixture/alpha-tool-policy" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" import { Plugin } from "@/plugin" @@ -34,6 +36,9 @@ const ctx: Tool.Context = { ask: () => Effect.void, } +/** #1129:Permission 引擎 mock 的 ask 记录(探针用例读它;每条用例前自行清零)。 */ +const askLog: string[] = [] + // Avoid the SDK Client here; other MCP tests mock it process-globally. class RawJsonRpcClient { private nextId = 1 @@ -144,6 +149,11 @@ async function buildTool() { } const layer = Layer.mergeAll( + // #1129:identity ask 经 Permission 引擎(gateToolExecution),探头从 ctx.ask 移到这里。 + Layer.mock(Permission.Service, { + ask: ((req: { permission: string }) => Effect.sync(() => void askLog.push(req.permission))) as Permission.Interface["ask"], + }), + inMemoryToolPolicyLayer(), Layer.mock(Plugin.Service, { trigger: ((_name: unknown, _input: unknown, output: unknown) => Effect.succeed(output)) as Plugin.Interface["trigger"], @@ -156,6 +166,7 @@ async function buildTool() { Layer.mock(MCP.Service, { tools: () => Effect.succeed(mcpTools), clients: () => Effect.succeed({ [SERVER]: {} as any }), + bindingFacts: () => Effect.succeed(undefined), }), ) return { @@ -300,8 +311,8 @@ describe("code mode integration (real MCP server)", () => { }) test("asks permission for each MCP call, keyed by canonical identity", async () => { - const asked: string[] = [] - const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) } + // #1129:ask 经 Permission 引擎(gateToolExecution),探头在共享 Permission mock 上。 + askLog.length = 0 await Effect.runPromise( tool.execute( { @@ -311,10 +322,10 @@ describe("code mode integration (real MCP server)", () => { return 'done' `, }, - permCtx, + ctx, ), ) - expect(asked).toEqual(["mcp:fixtures:add", "mcp:fixtures:get_text"]) + expect(askLog).toEqual(["mcp:fixtures:add", "mcp:fixtures:get_text"]) }) test("streams running/completed metadata for child calls over a real transport", async () => { diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 8ff044347c1e..1f0fadb421e5 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -5,6 +5,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" import { Permission } from "@/permission" +import { inMemoryToolPolicyLayer } from "../fixture/alpha-tool-policy" import { Plugin } from "@/plugin" import { Session } from "@/session/session" import { Tool } from "@/tool/tool" @@ -43,6 +44,8 @@ function harness(input: { servers: string[] permission?: PermissionV1.Rule[] trigger?: Plugin.Interface["trigger"] + /** #1129:E4 identity 闸经 gateToolExecution 走 Permission.Service(不再经 ctx.ask)。 */ + ask?: (req: { permission: string }) => Effect.Effect }) { const mcpTools = Object.fromEntries( Object.entries(input.mcpTools).map(([key, item]) => { @@ -60,6 +63,11 @@ function harness(input: { }), ) return Layer.mergeAll( + Layer.mock(Permission.Service, { + ask: ((req: { permission: string }) => + Effect.suspend(() => (input.ask ? input.ask(req) : Effect.void))) as Permission.Interface["ask"], + }), + inMemoryToolPolicyLayer(), Layer.mock(Plugin.Service, { trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), }), @@ -75,6 +83,7 @@ function harness(input: { Layer.mock(MCP.Service, { tools: () => Effect.succeed(mcpTools), clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))), + bindingFacts: () => Effect.succeed(undefined), }), ) } @@ -88,12 +97,13 @@ function build( servers?: string[], permission?: PermissionV1.Rule[], trigger?: Plugin.Interface["trigger"], + ask?: (req: { permission: string }) => Effect.Effect, ) { const names = serverNames(mcpTools, servers) return Effect.runPromise( CodeModeTool.pipe( Effect.flatMap(Tool.init), - Effect.provide(harness({ mcpTools, servers: names, permission, trigger })), + Effect.provide(harness({ mcpTools, servers: names, permission, trigger, ask })), ), ) } @@ -390,30 +400,35 @@ describe("code mode execute", () => { }) test("asks permission before each child tool call", async () => { - const asked: unknown[] = [] - const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) } + // #1129:identity ask 经 Permission 引擎(gateToolExecution),探头随之从 ctx.ask 移过去。 + const asked: string[] = [] const ok = () => ({ content: [{ type: "text", text: "ok" }] }) - const tool = await build({ a_tool: mcpTool("tool", ok), b_tool: mcpTool("tool", ok) }) - - await Effect.runPromise( - tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx), + const tool = await build({ a_tool: mcpTool("tool", ok), b_tool: mcpTool("tool", ok) }, undefined, undefined, undefined, (req) => + Effect.sync(() => void asked.push(req.permission)), ) - expect(asked.map((req: any) => req.permission)).toEqual(["mcp:a:tool", "mcp:b:tool"]) + await Effect.runPromise(tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, ctx)) + + expect(asked).toEqual(["mcp:a:tool", "mcp:b:tool"]) }) test("a denied permission fails the child call with a catchable message, not the whole execute", async () => { - const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) } const called: string[] = [] - const tool = await build({ - a_tool: mcpTool("tool", () => { - called.push("a") - return { content: [{ type: "text", text: "ok" }] } - }), - }) + const tool = await build( + { + a_tool: mcpTool("tool", () => { + called.push("a") + return { content: [{ type: "text", text: "ok" }] } + }), + }, + undefined, + undefined, + undefined, + () => Effect.die(new Error("permission denied by user")) as Effect.Effect, + ) const output = await Effect.runPromise( - tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx), + tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, ctx), ) expect(output.output).toBe("denied: permission denied by user") @@ -736,15 +751,16 @@ describe("code mode permission visibility", () => { expect(allowed.output).toBe("ok") }) - test("an ask-level tool remains callable and still prompts via ctx.ask", async () => { + test("an ask-level tool remains callable and still prompts via the Permission engine", async () => { const asked: string[] = [] - const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) } const tool = await build( { github_list_issues: mcpTool("list_issues", ok) }, ["github"], [askRule("github_list_issues")], + undefined, + (req) => Effect.sync(() => void asked.push(req.permission)), ) - const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx)) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx)) expect(out.output).toBe("ok") expect(asked).toEqual(["mcp:github:list_issues"]) }) diff --git a/packages/schema/src/alpha-tool-inventory.ts b/packages/schema/src/alpha-tool-inventory.ts new file mode 100644 index 000000000000..d0a00f6cce66 --- /dev/null +++ b/packages/schema/src/alpha-tool-inventory.ts @@ -0,0 +1,112 @@ +// alpha 自有文件(basename `alpha-*` 即 north-star 谓词因子②;ADR-043)。 +// +// REQ-131 / #1129(reopen)—— **dynamic tool policy inventory** 的 wire 形状(#724 §5 末条): +// +// > inventory/API 从 live registry/materialization 派生并返回 identity、可信 authority、 +// > 继承状态、effective state/reason 与 binding change;不得从历史 `ToolPart.display` +// > 或 UI 名单反推新调用授权。 +// +// 消费方是 #1130 的 Settings「工具」节(设计稿 `docs/design/2026-08-25-req131-settings-tool-policy/` +// §3 表);本文件与 `alpha-tool-policy.ts` 同一纪律 —— 纯数据、可被 renderer 原样 import。 +// 供给方是 `packages/opencode/src/permission/alpha-tool-inventory.ts`(它 decode 一遍本 schema +// 再返回:形状漂移在引擎侧 loud fail,不让 Settings 拿到解释不了的对象)。 +// +// 设计稿 §3 表 → 本形状的逐行映射: +// 四个分组 / 每类每服务条数 / 工具清单 ⇒ `services[].class` + `services[].tools[]`(条数 = length) +// 服务行(名称/展开) ⇒ `services[].source` + `services[].origin` +// 工具行名称 ⇒ `tools[].identity.name`(展示转义归 REQ-125) +// 三态控件写入(带当前 digest) ⇒ `services[].bindingDigest` / `tools[].bindingDigest` +// 「生效:…」+ 徽标(9 型 reason 逐型) ⇒ `tools[].effective.state/reason` +// 「继承:默认 / 你的设置(层级)」 ⇒ `effective.reason.kind = "default" | "user"(level)` +// 锁定行(cap-*)/ 损坏恢复 / managed 横幅 ⇒ `effective.reason.kind = cap-*` + 顶层 `user`/`managed` +// 「服务已变更」+ 重新启用 ⇒ `effective.reason.kind = "binding-changed"` + 当前 digest +// 「仅当前账户与当前项目」 ⇒ `partition` +// 「新发现」徽标 ⇒ `tools[].newlyDiscovered`(§3:无 broad override 的新动态工具) +// 「计费:按用量 / 未知」 ⇒ `tools[].billing`(缺失 ⇒ 显示「未知」,§8) +// 「N 项注册身份无法核验」 ⇒ `invalid.count`(owner 已裁:暴露计数;原因归开发者详情 ⇒ `invalid.entries[].detail`) +import { Schema, Types } from "effect" +import { ToolAuthority, ToolBillingFact, ToolIdentity, ToolIdentitySource } from "./tool-identity" +import { BindingDigest, ToolClass, ToolPolicyPartition, ToolPolicyRecord, ToolPolicyState } from "./alpha-tool-policy" + +/** resolver 的 9 型 reason(#1128 `EffectiveToolPolicyReason` 的 wire 镜像;一型不落)。 */ +export const EffectiveToolPolicyReasonV1 = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("invalid-identity"), detail: Schema.String }), + Schema.Struct({ kind: Schema.Literal("cap-managed") }), + Schema.Struct({ kind: Schema.Literal("cap-managed-unreadable"), detail: Schema.String }), + Schema.Struct({ kind: Schema.Literal("cap-entitlement"), verdict: Schema.Literals(["deny", "missing"]) }), + Schema.Struct({ kind: Schema.Literal("cap-hard-deny"), sources: Schema.Array(Schema.String) }), + Schema.Struct({ kind: Schema.Literal("quarantine"), detail: Schema.String }), + Schema.Struct({ kind: Schema.Literal("user"), level: Schema.Literals(["class", "service", "tool"]) }), + Schema.Struct({ kind: Schema.Literal("binding-changed"), level: Schema.Literals(["service", "tool"]) }), + Schema.Struct({ kind: Schema.Literal("default"), class: ToolClass }), +]).annotate({ discriminator: "kind", identifier: "EffectiveToolPolicyReasonV1" }) +export type EffectiveToolPolicyReasonV1 = Types.DeepMutable> + +export const EffectiveToolPolicyV1 = Schema.Struct({ + state: ToolPolicyState, + action: Schema.Literals(["allow", "ask", "deny"]), + reason: EffectiveToolPolicyReasonV1, +}).annotate({ identifier: "EffectiveToolPolicyV1" }) +export type EffectiveToolPolicyV1 = Types.DeepMutable> + +export const ToolPolicyInventoryToolV1 = Schema.Struct({ + /** exact canonical(tool 层 selector 的写入键;§3「exact auth lookup key 仍只有 canonical」)。 */ + canonical: Schema.String, + identity: ToolIdentity, + technicalId: Schema.String, + authority: ToolAuthority, + billing: Schema.optional(ToolBillingFact), + /** tool 层写 enabled 记录必须携带的**当前** binding digest(§5;派生不出来则缺席)。 */ + bindingDigest: Schema.optional(BindingDigest), + effective: EffectiveToolPolicyV1, + /** 该工具自己的 tool 层用户记录(radiogroup 的「用户 override」;无记录 = 无选中)。 */ + record: Schema.optional(ToolPolicyRecord), + /** §3:没有适用 broad override 的新**动态**工具(mcp/plugin 类)⇒ Settings 标「新发现」。 */ + newlyDiscovered: Schema.Boolean, +}).annotate({ identifier: "ToolPolicyInventoryToolV1" }) +export type ToolPolicyInventoryToolV1 = Types.DeepMutable> + +export const ToolPolicyInventoryServiceV1 = Schema.Struct({ + source: ToolIdentitySource, + origin: Schema.String, + class: ToolClass, + /** 服务级 authority(alpha-cloud verified ⇒「已核验」徽标)。 */ + authority: ToolAuthority, + /** service 层写 enabled 记录必须携带的**当前** binding digest(§5)。 */ + bindingDigest: Schema.optional(BindingDigest), + /** 该服务的 service 层用户记录。 */ + record: Schema.optional(ToolPolicyRecord), + tools: Schema.Array(ToolPolicyInventoryToolV1), +}).annotate({ identifier: "ToolPolicyInventoryServiceV1" }) +export type ToolPolicyInventoryServiceV1 = Types.DeepMutable> + +export const ToolPolicyInventoryV1 = Schema.Struct({ + version: Schema.Literal(1), + /** 「仅当前账户与当前项目」脚注的数据源。 */ + partition: ToolPolicyPartition, + /** 用户策略文档层状态:quarantined ⇒ Settings 整节横幅 + 只读 + 重置入口。 */ + user: Schema.Union([ + Schema.Struct({ status: Schema.Literals(["ok", "absent"]) }), + Schema.Struct({ status: Schema.Literal("quarantined"), reason: Schema.String }), + ]).annotate({ discriminator: "status" }), + /** managed cap 层状态:unreadable ⇒ 整节横幅(全部停用,同损坏态形制)。 */ + managed: Schema.Union([ + Schema.Struct({ status: Schema.Literal("ok") }), + Schema.Struct({ status: Schema.Literal("unreadable"), reason: Schema.String }), + ]).annotate({ discriminator: "status" }), + /** class 层用户记录(四组总开关的「用户 override」)。 */ + classRecords: Schema.Array(ToolPolicyRecord), + services: Schema.Array(ToolPolicyInventoryServiceV1), + /** 「N 项注册身份无法核验,已自动停用」:计数入正文,原因归开发者详情(owner 裁决 Q1)。 */ + invalid: Schema.Struct({ + count: Schema.Number, + entries: Schema.Array(Schema.Struct({ technicalId: Schema.String, detail: Schema.String })), + }), +}).annotate({ identifier: "ToolPolicyInventoryV1" }) +export type ToolPolicyInventoryV1 = Types.DeepMutable> + +const decodeToolPolicyInventory = Schema.decodeUnknownSync(ToolPolicyInventoryV1) + +export function parseToolPolicyInventory(value: unknown): ToolPolicyInventoryV1 { + return decodeToolPolicyInventory(value) as ToolPolicyInventoryV1 +} diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index d74a025fd897..3a2d4fa1f983 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -151,9 +151,11 @@ 25 packages/opencode test/permission/alpha-tool-policy.test.ts - #1128/#724 §2-§5:三态 resolver 全量判据 —— 四类分类与默认、手拼 wildcard 在 schema decode 即拒、service selector 结构匹配、exact tool>service>class、managed/entitlement/kill-switch cap 压过用户 enabled、binding guard(digest 不等/缺失回 ask,disabled 不因 rebind 失效)、坏文档整份 quarantine(损坏/未知版本/分区不符/selector 重复/单条非法)、reset 备份恢复、分区隔离、service API(setRecord/removeRecord/reset,quarantine 期间拒写);M 组是本票必修负向闸:OPENCODE_TEST_MANAGED_CONFIG_DIR 存在时系统 managed deny 仍生效(env 只 additive 且更低优先)。变异实测:摘 managed cap 判定 ⇒ R3/R8-11/M1/M4 红;系统目录改回 env 替换语义 ⇒ M1/M3/M4 红。条数=实测,不留余量 6 packages/opencode test/tool/alpha-mcp-alias-collision-lock.test.ts - #726/基线 I1 的**装配级**反向判据:起真的 MCP server,同一台或两台发布出同一个模型别名时,`MCP.tools()` 与 Code Mode(ToolRegistry.tools → describeCodeMode,不 mock MCP.Service)必须当场 die 成 ToolAliasCollisionError,且错误里两个 canonical identity 都点名。删掉 mcp/index.ts 的 `aliases.add(alias, identity)`、或换成「碰撞时静默丢弃其中一个」,四条正向用例全红。近似不碰撞的一对(foo/bar_baz + foo_bar/other)必须双双存活、键与 identity 各自正确,Code Mode 目录里两台 server 各占独立命名空间(锁住 groupByServer 禁 alias 前缀反解)——把别名钉死成常量时这两条负向对照全红。条数=实测,不留余量 3 packages/opencode test/tool/alpha-session-tools-alias-lock.test.ts - #972/基线 I1 的第三本账本(session 侧**跨来源**别名双射)的装配级反向判据:写一个真的 .opencode/tool 自定义工具 + 起一台真的 MCP server,走真的 SessionTools.resolve。删掉 src/session/tools.ts 的 `aliases.add(technicalId, display.identity)`、或换成「碰撞时静默丢弃其中一个」,两条正向用例全红(plugin↔MCP 与 plugin↔宿主资源工具 host::list_mcp_resources 两个域各一条 —— 后者另两本账本都盖不住,它同时锁住「检查必须在 register() 咽喉而不是下放进各来源循环」)。近似不撞的一对(plugin:weather:current + mcp:weather2:current)必须双双存活、键与 identity 各自正确 ——把碰撞改成无条件抛时该条当场红;它同时要求出现一个 MCP 来源的键,所以 experimentalCodeMode 被翻成 true 导致 MCP 循环早退时也红。条数=实测,不留余量 -7 packages/opencode test/tool/alpha-tool-policy-execution-gate.test.ts - #1129/#724 §6 E1-E3:执行咽喉 identity 三态的常驻闸(#725 探针是 .cases.ts 不进 bun test,这份才是门)。真 MCP server 计数 + 真插件 marker + 真 builtin 落盘,只驱动生产 `SessionTools.resolve().execute()`:E1 builtin deny 零落盘响亮拒、E1 plugin ask 批准前零 marker/reject 后仍零、E2 host deny 零 resources/list、E3 MCP ask 恰好问一次(once 一次批准走完一次调用、pending 清零)、负向对照四来源无规则时都真执行;sentinel 两条 —— host::StructuredOutput 目录闸免疫(exact canonical,deny-all 也移不掉;缺 identity 不享豁免)、_noop 在 deny-all 下仍注入且 execute 空操作。摘掉 session/tools.ts register() 的 identityGate、挪回 hook 之后、或恢复 MCP 循环内层 ask,当场红。条数=实测,不留余量 -3 packages/opencode test/tool/alpha-code-mode-child-policy.test.ts - #1129/#724 §6 E4 的运行期判据(#725 时代只有源码级确认):真 CodeModeTool + 真 CodeMode 解释器 + in-memory MCP server 计数,三个互相独立的探头(ask mock/plugin mock/server handler)往同一数组推事件。ask 未放行 ⇒ 插件钩子零触发、tools/call 零调用、程序响亮失败;放行 ⇒ 次序恰为 ask → tool.execute.before → transport 且传输恰一次;identity deny 的 child 不进 child catalog 且只有它消失(兄弟工具照常、目录拒的不该走到 ask)。把 invokeChildTool 的 ask 挪回 hook 之后 ⇒ 次序用例当场红。条数=实测,不留余量 -3 packages/opencode test/session/alpha-subtask-attachment-policy.test.ts - #1129/#724 §6 E6/E7 的运行期判据(#725 时代 E6 要起整回合、E7 无锁死断言):真 SessionPrompt.loop。E6 deny ⇒ DeniedError 收口、零子会话零 assistant tool part;E6 ask ⇒ 先进真 Permission 待批队列(挂起期间零子会话零持久化),reject ⇒ RejectedError fail-closed 仍零副作用 —— 摘掉 prompt.ts handleSubtask 的 identity ask,ask 用例 poll 超时当场红。E7 排除锁 ⇒ builtin::read 与 read ability 全 deny 时附件 ingestion 照样读出正文且全程零待批 —— 谁把 tool policy 接进 ingestion(动 execRead 的 ask: () => Effect.void),当场红。条数=实测,不留余量 +7 packages/opencode test/tool/alpha-tool-policy-execution-gate.test.ts - #1129/#724 §6 E1-E3:执行咽喉 identity 三态的常驻闸(#725 探针是 .cases.ts 不进 bun test,这份才是门)。真 MCP server 计数 + 真插件 marker + 真 builtin 落盘,只驱动生产 `SessionTools.resolve().execute()`:E1 builtin deny 零落盘响亮拒、E1 plugin ask 批准前零 marker/reject 后仍零、E2 host deny 零 resources/list、E3 MCP ask 恰好问一次(once 一次批准走完一次调用、pending 清零)、负向对照四来源无规则时都真执行(#1129 reopen 起中性底 = 文档轴 class 层 enabled 夹具,经生产 setRecord;文档轴自己的判据在 doc-axis 闸);sentinel 两条 —— host::StructuredOutput 目录闸免疫(exact canonical,deny-all 也移不掉;缺 identity 不享豁免)、_noop 在 deny-all 下仍注入且 execute 空操作。摘掉 session/tools.ts register() 的 identityGate、挪回 hook 之后、或恢复 MCP 循环内层 ask,当场红。条数=实测,不留余量 +5 packages/opencode test/tool/alpha-code-mode-child-policy.test.ts - #1129/#724 §6 E4 的运行期判据(#725 时代只有源码级确认):真 CodeModeTool + 真 CodeMode 解释器 + in-memory MCP server 计数,三个互相独立的探头(Permission mock/plugin mock/server handler)往同一数组推事件。ask 未放行 ⇒ 插件钩子零触发、tools/call 零调用、程序响亮失败;放行 ⇒ 次序恰为 ask → tool.execute.before → transport 且传输恰一次;identity deny 的 child 不进 child catalog 且只有它消失(兄弟工具照常、目录拒的不该走到 ask)。#1129 reopen 补策略文档轴两条:tool 层 disabled ⇒ child 不进 catalog、held 调用零 ask/零 hook/零传输;同一次 execute 内收紧文档轴 ⇒ held callTool 闭包的下一次调用当场 deny(调用时重读,传输不增长)。把 invokeChildTool 的 ask 挪回 hook 之后 ⇒ 次序用例当场红;把 gate 换成注册期快照 ⇒ 重读用例当场红。条数=实测,不留余量 +4 packages/opencode test/session/alpha-subtask-attachment-policy.test.ts - #1129/#724 §6 E6/E7 的运行期判据(#725 时代 E6 要起整回合、E7 无锁死断言):真 SessionPrompt.loop。E6 deny ⇒ DeniedError 收口、零子会话零 assistant tool part;E6 ask ⇒ 先进真 Permission 待批队列(挂起期间零子会话零持久化),reject ⇒ RejectedError fail-closed 仍零副作用 —— 摘掉 prompt.ts handleSubtask 的 identity ask,ask 用例 poll 超时当场红。E6 文档轴(#1129 reopen)⇒ 生产 setRecord 写 builtin::task=disabled 后 loop 具名拒绝(点名 canonical 与 alpha-tool-policy 来源)、零子会话零持久化零待批。E7 排除锁 ⇒ builtin::read 与 read ability 全 deny 时附件 ingestion 照样读出正文且全程零待批 —— 谁把 tool policy 接进 ingestion(动 execRead 的 ask: () => Effect.void),当场红。条数=实测,不留余量 +7 packages/opencode test/tool/alpha-tool-policy-doc-axis-gate.test.ts - #1129(reopen)/#724 §2/§4/§5/§6:策略文档轴抵达目录与执行咽喉的常驻闸(ruleset 轴归 execution-gate,本文件量文档轴)。真 store(生产 setRecord/removeRecord/reset)+ 真 MCP server 计数 + 真插件 marker,只驱动生产 SessionTools.resolve().execute() 与 LLMRequestPrep.prepare():D1 四类默认(MCP 默认 ask —— 目录照旧广告、执行挂起、批准前零 tools/call);D2 本地类默认 enabled 零打扰;D3 用户 tool 层 disabled ⇒ 目录只删它 + 执行具名拒绝(点名 canonical + alpha-tool-policy)零传输零待批;D4 executor 调用时重读 —— 目录发出、对象在手,再 setRecord disabled ⇒ 同一对象当场 deny、removeRecord ⇒ 同一对象恢复(「executor 用目录快照」的错误实现只有这条能翻);D5 binding guard —— service 层 enabled 必须绑 inventory 发放的当前 digest,rebind(同键换 URL)后 held 对象回到 ask、新旧 server 都零调用;D6 quarantine ⇒ 用户可配置工具全体 disabled、生产 reset 恢复默认(恢复后 MCP 是 ask 不是照跑);D7 先 always 后 class 禁用 ⇒ grant 撬不开 deny。摘 identityGate 的 resolve 重读/摘目录 doc 过滤,当场红。条数=实测,不留余量 +5 packages/opencode test/permission/alpha-tool-inventory.test.ts - #1129(reopen)/#724 §5 末条 + 设计稿 §3 表:dynamic tool policy inventory 的常驻闸。I1 从 live registry/materialization 派生 —— builtin/plugin/host/MCP 四来源在场、internal sentinel(StructuredOutput/_noop)缺席、identity/authority/每层 bindingDigest/「新发现」/无法核验计数(owner 裁决 Q1)逐项在场、分区与引擎一致;I2 徽标说真话 —— inventory 说 disabled 的工具,生产 executor 当场拒绝(同一 resolver 两面一致)、record 原样回显;I3 binding change —— inventory 发放的 digest 正是让 enabled 生效的那一个,rebind 后 digest 换新、reason 翻 binding-changed;I4 config ruleset 的 identity deny 折进 cap-hard-deny(sources=permission-ruleset),Settings 能解释锁定原因;I5 文档损坏 ⇒ user.status=quarantined + 工具逐行 quarantine-disabled。list() 返回值过 schema decode(形状漂移 loud fail)。条数=实测,不留余量 34 packages/ui-mac src/main/ext-package-ledger-uninstall.test.ts packages/ui-mac/src/main/ext-fs-installer.test.ts,packages/ui-mac/src/main/ext-config.test.ts #706:直接卸载的 claim-aware 判决 —— bundle-only(无 standalone claim 可释放)必须写盘前拒绝而不是谎报 ok,双 owner 只释放 standalone,legacy-protected 永不 GC;并钉住 removeRecordV2 的三形状(拒绝支/只释放支/solo 仍去得了账)。下界=实际条数 34,不留余量:留余量则删一格仍能过 26 packages/ui-mac src/main/ext-package-lifecycle.test.ts - #698:图 diff / claim 转移 / 删除判决 / 冲突闸的纯代数,外加 fixed-seed 有界 model test(参照实现独立编写,失败输出 seed + shrunk sequence)。断言的是「同一张图变成那张图,判决唯一」这条跨 update/uninstall 两条路径的保证 —— 删掉它,claim 转移顺序、replaced/optional-changed 的判据、legacy/unmanaged 永不 GC 就再没有别的东西看着。`#764` 起还含「已安装版本由 root 组件 record 派生」的判据(root 那条刻意不在数组第一个)。下界=实际条数 26,不留余量 21 packages/ui-mac src/main/ext-package-lifecycle-permutations.test.ts - #698:canonical permutations(standalone→Bundle / Bundle→standalone / Bundle A/B 共享 child / legacy 存量)与顺序、响亮失败,全部落在真账本 + 真实物上(假 installer 会真的删,所以「一件都没动」是有代价的断言)。另钉住 ext-receipt-v2 的两处:applyPackageMutation 的作用域闸(不得释放别人的 owner / 认领图外 child / 删还在图里的 child)与被删掉的 withoutClaim(removedKeys)——那一行是唯一能让「删掉还有别人在用的 child」通过写盘的通道。R1 修复后追加:账本不自洽的**整类**(dangling claim / unknown child / 孤儿 owner / 图篡改)一律计划期拒且**删除接缝零调用**,以及首个 V3 Bundle 与用户既有同名安装碰撞时那份存量必须拿到 legacy 保护。`#809` 追加:plugin 卸载臂缺 `plugin-path:` 账本记录 / 账本路径越出 plugins 根,两条都 fail-closed 且零 `removePluginPath` 调用;原「不认识的 kind」负例从 plugin 换成 command(plugin 已经有接缝了,拿它当负例是轴不对)。下界=实际条数 21,不留余量 From f787f60a83adb78b8d9a3094c7e7c4d85261dea1 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Wed, 26 Aug 2026 09:21:56 -0400 Subject: [PATCH 2/2] =?UTF-8?q?[#1129]=20ext=20=E6=AC=A1=E5=BA=8F=E9=94=81?= =?UTF-8?q?=E9=87=8D=E9=94=9A=E5=88=B0=E5=85=B1=E4=BA=AB=20gate;=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF=E7=BC=BA=E5=8F=A3=E5=9B=9E=E5=86=99;CHANGELO?= =?UTF-8?q?G/gate-files=20=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ext/src/cloud-websearch-kill.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/ext/src/cloud-websearch-kill.test.ts b/packages/ext/src/cloud-websearch-kill.test.ts index b9d3d3eaad69..2f1765dedabb 100644 --- a/packages/ext/src/cloud-websearch-kill.test.ts +++ b/packages/ext/src/cloud-websearch-kill.test.ts @@ -341,15 +341,23 @@ describe("上游次序前提(#1129:identity ask 早于 trigger,trigger 早于传 test("普通 MCP 链:identity 闸在 register 咽喉上,MCP 循环里 trigger 直达传输、其后再无 ask", () => { const body = readFileSync(join(repoRoot, "packages/opencode/src/session/tools.ts"), "utf8") // ① register 咽喉上的 identity 闸在:闸在 wrapper、钩子在被包的 execute 内, - // 「ask 早于 trigger」是结构性的,不靠行序纪律。 + // 「ask 早于 trigger」是结构性的,不靠行序纪律。#1129 reopen 后 wrapper 里的载体 + // 是共享 gate(gateToolExecution:文档轴 deny → 单次 Permission.ask),锚点随之换。 const gate = body.indexOf("const identityGate") expect(gate).toBeGreaterThanOrEqual(0) const registerFn = body.indexOf("const register =", gate) expect(registerFn).toBeGreaterThan(gate) - const gateAsk = body.indexOf(".ask(", gate) - expect(gateAsk).toBeGreaterThan(gate) - expect(gateAsk).toBeLessThan(registerFn) + const gateCall = body.indexOf("AlphaToolPolicyGate.gateToolExecution(", gate) + expect(gateCall).toBeGreaterThan(gate) + expect(gateCall).toBeLessThan(registerFn) expect(body.indexOf("identityGate(value, display)", registerFn)).toBeGreaterThan(registerFn) + // ①b gate 模块内部:deny 判定之后恰有一次 permission.ask(单一问询点;findLast 组合在其中)。 + const gateBody = readFileSync(join(repoRoot, "packages/opencode/src/permission/alpha-tool-policy-gate.ts"), "utf8") + const gateFn = gateBody.indexOf("export const gateToolExecution") + expect(gateFn).toBeGreaterThanOrEqual(0) + const gateAsk = gateBody.indexOf("input.permission.ask(", gateFn) + expect(gateAsk).toBeGreaterThan(gateFn) + expect(gateBody.indexOf("input.permission.ask(", gateAsk + 1)).toBe(-1) // ② MCP 循环:trigger 之后直达传输,不许再有第二个 ask(旧的重复 canonical ask 位点)。 const loop = body.indexOf("McpCatalog.convertTool(") expect(loop).toBeGreaterThanOrEqual(0) @@ -359,13 +367,16 @@ describe("上游次序前提(#1129:identity ask 早于 trigger,trigger 早于传 expect(transport).toBeGreaterThan(trigger) const staleAsk = body.indexOf("ctx.ask(", trigger) expect(staleAsk === -1 || staleAsk > transport).toBe(true) + const staleGate = body.indexOf("gateToolExecution(", trigger) + expect(staleGate === -1 || staleGate > transport).toBe(true) }) test("code-mode 链:invokeChildTool 里 ask 在 trigger 之前,trigger 在 callTool 之前", () => { const body = readFileSync(join(repoRoot, "packages/opencode/src/tool/code-mode.ts"), "utf8") const fn = body.indexOf("invokeChildTool = Effect.fn") expect(fn).toBeGreaterThanOrEqual(0) - const ask = body.indexOf("ctx.ask(", fn) + // #1129 reopen:载体 = 共享 gate(文档轴 deny → 单次 Permission.ask),仍必须先于 trigger。 + const ask = body.indexOf("AlphaToolPolicyGate.gateToolExecution(", fn) const trigger = body.indexOf('"tool.execute.before"', fn) const callTool = body.indexOf(".callTool(", fn) expect(ask).toBeGreaterThan(fn)