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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 项」,不会拿一个被截断的数字冒充完整计数。同时新增一个「复制清单」按钮——这是把这些地址带出隔离窗口的唯一出口,且只在你按下时才发生。诚实的边界:①条目是**来源域或本次任务目录内的相对路径**,不是文档请求的完整链接(隔离宿主刻意只记来源域、不留路径与查询串,以免不可信文档借这个面把数据带出去),被拒绝的**页面跳转**则根本不入记录——所以这里回答的是「哪些来源被挡了」,不是「你刚点的那个链接是什么」;②隔离窗口的拦截一条都没有放宽:它依旧不开新窗口、不跳转、不发任何外部请求、不下载、不给任何权限,预览里也依然没有任何通往系统浏览器的路径。
Expand Down
29 changes: 25 additions & 4 deletions docs/design/2026-08-25-req131-settings-tool-policy/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 型,一型不落):
Expand Down Expand Up @@ -235,7 +235,28 @@ Settings 面板显示「已停用」,而模型照调 —— 正是 `#1121` 那
「Settings 和调用方不得手拼 wildcard」「禁止各处拼 `mcp:<server>:*`」;且 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 交付补齐(见上节坐标)。
21 changes: 16 additions & 5 deletions packages/ext/src/cloud-websearch-kill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, McpTool>>
/**
* #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<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
Expand Down Expand Up @@ -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<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
Expand Down Expand Up @@ -1044,6 +1069,7 @@ const layer = Layer.effect(
clients,
instructions,
tools,
bindingFacts,
prompts,
resources,
resourceTemplates,
Expand Down
Loading
Loading