diff --git a/docs/README.md b/docs/README.md index 4355037c5767..e6c1b986ff91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ accepted contracts. Follow the [Alpha Documentation Contract](https://github.com | How long the two outbound fetch chains in `packages/core` can actually block, and which shipped shapes never reach them | [`architecture/2026-08-23-network-timeout-recon.md`](architecture/2026-08-23-network-timeout-recon.md) | | Platform and endpoint integration | [`contracts/`](contracts/) | | Session tool permission DTOs and decision receipts | [`contracts/session-permission.md`](contracts/session-permission.md) | +| REQ-131 分层工具策略:三态/四类/selector、cap 合成、binding guard、分区持久化与 V1 session grant 语义 | [`contracts/tool-policy.md`](contracts/tool-policy.md) | | Build, distribution, CI, uninstall, and Settings recovery operations | [`runbooks/`](runbooks/) | | Product and visual design assets | [`design/README.md`](design/README.md) | | Point-in-time audits and screenshots | [`audits/README.md`](audits/README.md) | diff --git a/docs/contracts/tool-policy.md b/docs/contracts/tool-policy.md new file mode 100644 index 000000000000..5f377e2d4b3b --- /dev/null +++ b/docs/contracts/tool-policy.md @@ -0,0 +1,90 @@ +--- +title: Hierarchical tool policy contract (REQ-131) +kind: contract +status: active +owners: + - alpha-code +last_reviewed: 2026-08-25 +review_after: 2027-02-25 +--- + +# Hierarchical tool policy contract + +本契约是 `jinjunnn/alpha-code#724` CLOSE_DECIDE(2026-08-17)§2–§5 的实现落点,由 +`#1128` 交付。它定义三态工具策略的**数据形状、合成语义与消费 API**;目录/执行咽喉 +的接线(E1–E6)归 `#1129`,Settings 编辑面归 `#1130`。V2 的会话审批(receipts / +saved-rule)另见 [`session-permission.md`](session-permission.md),两者不共享引擎。 + +## 形状(SOT = `packages/schema/src/alpha-tool-policy.ts`) + +- 三态 `ToolPolicyState = enabled | ask | disabled`,经 `toPermissionAction` 编译到 + 现有 V1 Permission 的 `allow | ask | deny`。不存在第四种状态,也没有第二个审批引擎。 +- 四类 `ToolClass = builtin | alpha-cloud | third-party-mcp | plugin`。分类唯一可信输入 + 是 `identity.source` 与 **verified** `authority.kind`(`classifyTool`);标题、 + annotation、technicalId、URL 相似性一律不是输入。用户可调用的 `host` / `builtin-v2` + 归本地类。默认:本地 `enabled`,其余一律 `ask`;identity 铸不出 canonical ⇒ `disabled`。 +- **结构化 selector**(`ToolPolicySelector`):`class` / `service (source, origin)` / + `tool (canonical)` 三层。匹配是结构相等(`selectorMatches`),**没有字符串通配语义**; + `name="*"` 的 canonical 是 `%2A`,只匹配那个字面工具;手拼 `mcp::*` 在 schema + decode 时 loud fail。同一 selector 只允许一条记录(`selectorKey` 唯一),重复即坏文档。 +- 持久化文档 `ToolPolicyDocumentV1 = { version: 1, partition, records[] }`,按 + `(account subject 或 anonymous, workspace/project id)` 分区,一分区一文件 + (文件名 = 分区 canonical JSON 的 sha256,SOT = + `packages/opencode/src/permission/alpha-tool-policy-store.ts`)。 + +## 合成语义(SOT = `packages/opencode/src/permission/alpha-tool-policy.ts`) + +`resolveToolPolicy` 按 #724 §4 的终局顺序(第一版 `deny > ask > allow` 排序已否决): + +1. **cap,不可突破**:managed deny(见下)、服务端 entitlement `missing|deny`、 + 现有 sovereignty / kill-switch deny(`hardDeny` 输入,只取交集不替换)。 + 任一命中 ⇒ `disabled`。managed 层 `unreadable` 同样 ⇒ `disabled`。 +2. **用户层**(当前分区):exact tool > service > class。`disabled` 不可被任何下层 + 撬开;`enabled` 在 service/tool 层必须过 **binding guard** —— 记录携带的 + `bindingDigest` 与主体当前 binding 逐字相等才生效,否则回 `ask` + (reason `binding-changed`);class 层是 broad intent,不绑定 binding。 + 文档 quarantine(损坏 / 未知版本 / 分区不符 / selector 重复 / 记录非法)⇒ + 所有用户可配置工具 `disabled`,恢复入口是 `reset`(坏文件挪去 + `.quarantined-` 备份,回到批准默认)。 +3. **默认**:按四类;新发现工具吃默认。 + +Binding digest 来源(#724 §5):Alpha Cloud 复用 verified `authority.evidenceDigest`; +第三方 MCP 用 `mcpBindingDigest`(去秘密后的 definition:remote 取 `url`、local 取 +`command+cwd`;headers / environment / oauth / enabled / timeout 不参与);plugin 经 +`deriveBindingDigest` 对安装 receipt / manifest / loader generation 派生(由 #1129 供值)。 + +## managed cap(#1128 必修) + +SOT = `packages/opencode/src/permission/alpha-managed-policy.ts`。与上游 +`config/managed.ts` 的 `OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()` +不同:**系统 managed 目录无条件读取,env 不能替换它**。`OPENCODE_TEST_MANAGED_CONFIG_DIR` +只是 additive 的最低优先来源;系统目录 / MDM plist 存在但读不出 ⇒ 整层 `unreadable` +⇒ resolver `disabled`(不静默丢 org deny)。测试对系统目录的控制**仅经函数参数注入**。 +负向闸在 `packages/opencode/test/permission/alpha-tool-policy.test.ts`(M 组)。 + +## V1 审批层的会话语义(SOT = `packages/opencode/src/permission/index.ts`) + +`#1122` B9/B13 的修复,#724 §4.4/§4.5: + +- `always` 产生的是 **session grant**(`sessionID + permission + resource pattern`), + 不是规则:换会话必须重新进待批队列;instance 内跨会话不共享。 +- grant 只能 **discharge ask**:deny / allow 只由 ruleset 决定,任何旧批条都压不过 + 后来收紧的 deny(结构性,不是排序巧合)。 +- `once` 只放行当次,不落账。 +- `clearGrants({ sessionID? })` 是切账户 / 登出的清账口,由 #1129/#1130 接线。 +- 上游 `test/permission/next.test.ts` 的 “reply - always persists approval and + resolves” 断言的是 B13 的缺陷行为(跨会话直通),与本契约相悖;该文件是上游 + 资产、不在任何 alpha 门内,按 #1128 交付时的实测记录为已知分歧。 + +## 消费 API + +`AlphaToolPolicy.Service`(LayerNode `AlphaToolPolicy.node`): +`resolve(subject, caps?)`(每次调用重读 cap 与文档 —— #724 §6 要求 executor 调用时 +重读)、`inspect()`、`setRecord` / `removeRecord`(quarantine 期间拒写,先 `reset`)、 +`reset()`。账户 subject 今天默认 `anonymous`(引擎侧尚无账户权威),经 layer 注入。 + +判据:`packages/opencode/test/permission/alpha-session-grants.test.ts`、 +`packages/opencode/test/permission/alpha-tool-policy.test.ts`(均登记于 +`scripts/gate-files.tsv`),生产双咽喉取证见 +`packages/opencode/test/tool/alpha-725-policy-chokepoints.cases.ts`(#1128 后 +B9/B13 转绿,B2/B3/B4/B6/B10/B11 属 #1129)。 diff --git a/packages/opencode/src/permission/alpha-managed-policy.ts b/packages/opencode/src/permission/alpha-managed-policy.ts new file mode 100644 index 000000000000..3458be18da0d --- /dev/null +++ b/packages/opencode/src/permission/alpha-managed-policy.ts @@ -0,0 +1,163 @@ +// alpha 自有文件(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// REQ-131 / #1128 —— 分层工具策略的 **managed cap 读取器**(#724 CLOSE_DECIDE §4 第 1 步)。 +// +// ── 为什么不用 `ConfigManaged.managedConfigDir()`(本票必修条款)──────────────── +// `packages/opencode/src/config/managed.ts:31-32` 是 +// `process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()` —— +// env 一旦存在,**系统 managed 目录被整个替换掉**:org/MDM 下发的 deny 从 cap 层 +// 静默消失。cap 的定义是「不可突破」,一个进程环境变量就能摘掉的东西不配叫 cap。 +// 本读取器因此**无条件读系统目录**;`OPENCODE_TEST_MANAGED_CONFIG_DIR` 只作为 +// **additive 且优先级更低**的补充来源(它能加规则,压不掉系统规则)。 +// 负向闸:`test/permission/alpha-tool-policy.test.ts` 证明「env 存在时系统 managed +// deny 仍生效」。 +// +// managed.ts 本体是上游文件(north-star UPSTREAM_PATHS,未收编),不能改它 —— +// 所以系统目录的平台映射在这里**逐字重述**(darwin/win32/linux 三行)。这份重复 +// 是刻意的、登记过的:上游若改那三行,alpha 的 cap 目录不跟着漂,方向是 fail-closed +// (我们至多多读一个不存在的目录,不会少读系统目录)。 +// +// ── 优先级(低 → 高;`findLast` 语义下排在后面的赢)────────────────────────── +// 1. OPENCODE_TEST_MANAGED_CONFIG_DIR(additive,最低) +// 2. 系统 managed 目录(darwin: /Library/Application Support/opencode …) +// 3. macOS MDM managed preferences(.mobileconfig;上游 config.ts 同款「override everything」) +// +// ── 坏输入的方向(§5 同源:不得静默忽略一条可能原本是 deny 的坏记录)──────────── +// 任一 **managed 来源**(系统目录文件 / MDM plist)存在但读不出 ⇒ 整个 cap 层 +// `unreadable`,resolver 对所有用户可配置工具判 disabled。测试目录读不出**不算** +// unreadable —— 它是 additive 的测试便利,不承载 org 意志;丢掉它只会更严,不会更松。 +import { existsSync, readFileSync } from "fs" +import path from "path" +import { Wildcard } from "@opencode-ai/core/util/wildcard" +import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Schema } from "effect" +import { ConfigManaged } from "@/config/managed" +import { ConfigParse } from "@/config/parse" +import { fromConfig } from "./index" + +/** 与 `config/managed.ts` 的私有 `systemManagedConfigDir()` 逐字对应(见抬头)。 */ +export function systemManagedPolicyDir(platform: NodeJS.Platform = process.platform): string { + switch (platform) { + case "darwin": + return "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/Library/Application Support/opencode" + case "win32": + return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") + default: + return "/etc/opencode" + } +} + +/** 与上游 `config/config.ts` 读 managed 目录的文件名单逐字对应。 */ +const MANAGED_FILES = ["opencode.json", "opencode.jsonc"] as const + +export type ManagedPolicyResult = + | { status: "ok"; ruleset: PermissionV1.Ruleset; sources: readonly string[] } + | { status: "unreadable"; reason: string; source: string } + +const decodePermission = Schema.decodeUnknownSync(ConfigPermissionV1.Info) + +function rulesFromConfigText( + text: string, + source: string, +): { ok: true; rules: PermissionV1.Rule[] } | { ok: false; reason: string } { + try { + const parsed = ConfigParse.jsonc(text, source) + if (parsed === null || parsed === undefined) return { ok: true, rules: [] } + if (typeof parsed !== "object" || Array.isArray(parsed)) + return { ok: false, reason: "managed config root is not an object" } + const permission = (parsed as Record)["permission"] + if (permission === undefined) return { ok: true, rules: [] } + return { ok: true, rules: fromConfig(decodePermission(permission)) } + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } +} + +/** + * 读出 managed cap 的 permission ruleset(供 resolver 的第 1 步)。 + * + * `options` **仅经测试注入**(必修条款允许的口):生产调用方一律零参调用; + * 没有任何 env / 配置能把 `systemDir` 换掉 —— 这正是与上游 `managedConfigDir()` + * 的区别所在。`plist` 注入仅用于让测试不依赖本机 MDM 状态。 + */ +export async function readManagedPolicy(options?: { + readonly systemDir?: string + readonly plist?: { source: string; text: string } | null +}): Promise { + const ruleset: PermissionV1.Rule[] = [] + const sources: string[] = [] + + // 1. 测试目录:additive,最低优先。读不出不致命(见抬头)。 + const testDir = process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] + if (testDir && existsSync(testDir)) { + for (const file of MANAGED_FILES) { + const source = path.join(testDir, file) + if (!existsSync(source)) continue + try { + const parsed = rulesFromConfigText(readFileSync(source, "utf8"), source) + if (parsed.ok) { + ruleset.push(...parsed.rules) + sources.push(source) + } + } catch { + // additive 来源读不出:忽略即更严,不影响系统 cap。 + } + } + } + + // 2. 系统 managed 目录:无条件读,env 不可替换。存在但读不出 ⇒ 整层 unreadable。 + const systemDir = options?.systemDir ?? systemManagedPolicyDir() + if (existsSync(systemDir)) { + for (const file of MANAGED_FILES) { + const source = path.join(systemDir, file) + if (!existsSync(source)) continue + let text: string + try { + text = readFileSync(source, "utf8") + } catch (error) { + return { status: "unreadable", reason: error instanceof Error ? error.message : String(error), source } + } + const parsed = rulesFromConfigText(text, source) + if (!parsed.ok) return { status: "unreadable", reason: parsed.reason, source } + ruleset.push(...parsed.rules) + sources.push(source) + } + } + + // 3. MDM managed preferences:最高优先(上游同款「override everything」)。 + // readManagedPreferences 自身对 plutil 非零码返回 undefined;能抛到这里的只有 + // 环境级异常(spawn 失败等)—— fail-closed,按 unreadable 上报。 + let plist: { source: string; text: string } | null | undefined + if (options !== undefined && "plist" in options) plist = options.plist + else { + try { + plist = await ConfigManaged.readManagedPreferences() + } catch (error) { + return { + status: "unreadable", + reason: error instanceof Error ? error.message : String(error), + source: "managed-preferences", + } + } + } + if (plist) { + const parsed = rulesFromConfigText(plist.text, plist.source) + if (!parsed.ok) return { status: "unreadable", reason: parsed.reason, source: plist.source } + ruleset.push(...parsed.rules) + sources.push(plist.source) + } + + return { status: "ok", ruleset, sources } +} + +/** + * managed cap 对一个 canonical identity 的判定 —— 与既有 `Permission.disabled` 的 + * identity hard-deny 语义**同一条**:最后一条 `Wildcard.match(canonical, rule.permission)` + * 命中的规则,`pattern === "*"` 且 `action === "deny"` 才构成 cap deny。 + * managed 的 allow 只表示「上限不阻止」,不给下层扩权(§4)。 + */ +export function managedCapDenies(canonical: string, ruleset: PermissionV1.Ruleset): boolean { + const rule = ruleset.findLast((item) => Wildcard.match(canonical, item.permission)) + return rule?.pattern === "*" && rule.action === "deny" +} diff --git a/packages/opencode/src/permission/alpha-tool-policy-store.ts b/packages/opencode/src/permission/alpha-tool-policy-store.ts new file mode 100644 index 000000000000..2e8182c9ce11 --- /dev/null +++ b/packages/opencode/src/permission/alpha-tool-policy-store.ts @@ -0,0 +1,123 @@ +// alpha 自有文件(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// REQ-131 / #1128 —— 用户工具策略的 **versioned 持久化**(#724 CLOSE_DECIDE §5)。 +// +// · 文档按 `(account subject 或 anonymous, workspace/project identity)` 分区,一分区一文件; +// 文件名 = 分区 canonical JSON 的 sha256 —— 不把账户/路径明文写进文件名, +// 分区明文写在**文档体内**,加载时与当前分区核对:核不上(把别的账户的文件拷过来) +// = quarantine,不是静默采用。 +// · 文件不存在 = 首次使用,采用批准默认(`absent`,不是错误)。 +// · 文档损坏 / 部分非法 / 未知版本 / 分区不符 / selector 重复 = **整份 quarantine**: +// 不得静默忽略一条可能原本是 deny 的坏记录。恢复入口是 `reset`(把坏文件挪去 +// `.quarantined-` 备份,回到默认),给 Settings(#1130)呈现。 +// · 写入原子:tmp + rename,不留半截文档。 +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs" +import { createHash } from "node:crypto" +import path from "path" +import { + parseToolPolicyDocument, + selectorKey, + type ToolPolicyDocumentV1, + type ToolPolicyPartition, + type ToolPolicyRecord, +} from "@opencode-ai/schema/alpha-tool-policy" + +/** + * canonical JSON(键排序、丢 undefined)—— 域内既有算法(alpha-cloud-authority 同款)。 + * 用于分区文件名与 binding digest;非 JSON 值 loud fail,不静默吞。 + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]` + if (value && typeof value === "object") { + const entries = Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}` + } + const encoded = JSON.stringify(value) + if (encoded === undefined) throw new Error("tool policy evidence contains a non-JSON value") + return encoded +} + +export function canonicalJsonDigest(value: unknown): string { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}` +} + +export function policyFilePath(baseDir: string, partition: ToolPolicyPartition): string { + const digest = canonicalJsonDigest({ account: partition.account, workspace: partition.workspace }) + return path.join(baseDir, `${digest.slice("sha256:".length)}.json`) +} + +export type PolicyLoadResult = + | { status: "ok"; doc: ToolPolicyDocumentV1 } + | { status: "absent" } + | { status: "quarantined"; reason: string; file: string } + +/** selector 唯一性(§3):重复 = 两条记录可能互相矛盾,静默取一条会丢 deny ⇒ 整份坏。 */ +function duplicateSelector(records: readonly ToolPolicyRecord[]): string | undefined { + const seen = new Set() + for (const record of records) { + const key = selectorKey(record.selector) + if (seen.has(key)) return key + seen.add(key) + } + return undefined +} + +export function loadPolicyDocument(baseDir: string, partition: ToolPolicyPartition): PolicyLoadResult { + const file = policyFilePath(baseDir, partition) + if (!existsSync(file)) return { status: "absent" } + let doc: ToolPolicyDocumentV1 + try { + doc = parseToolPolicyDocument(JSON.parse(readFileSync(file, "utf8"))) + } catch (error) { + return { + status: "quarantined", + reason: `tool policy document failed to parse: ${error instanceof Error ? error.message : String(error)}`, + file, + } + } + if (doc.partition.account !== partition.account || doc.partition.workspace !== partition.workspace) + return { + status: "quarantined", + reason: "tool policy document belongs to a different account/workspace partition", + file, + } + const duplicate = duplicateSelector(doc.records) + if (duplicate !== undefined) + return { status: "quarantined", reason: `duplicate selector record: ${duplicate}`, file } + return { status: "ok", doc } +} + +export function savePolicyDocument( + baseDir: string, + partition: ToolPolicyPartition, + records: readonly ToolPolicyRecord[], +): void { + const duplicate = duplicateSelector(records) + if (duplicate !== undefined) throw new Error(`duplicate selector record: ${duplicate}`) + // decode 一遍 = 写入前走完整 schema 校验(binding digest 在场性、canonical 规范形…), + // 坏记录在写入者手里 loud fail,而不是落盘后让所有工具进 quarantine。 + const doc = parseToolPolicyDocument({ + version: 1, + partition: { account: partition.account, workspace: partition.workspace }, + records, + }) + const file = policyFilePath(baseDir, partition) + mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.tmp-${process.pid}-${Date.now()}` + writeFileSync(tmp, JSON.stringify(doc, null, 2)) + renameSync(tmp, file) +} + +/** quarantine 恢复入口:坏文件挪去带时间戳的备份,下次加载回到 `absent`(批准默认)。 */ +export function resetPolicyDocument( + baseDir: string, + partition: ToolPolicyPartition, +): { backup?: string } { + const file = policyFilePath(baseDir, partition) + if (!existsSync(file)) return {} + const backup = `${file}.quarantined-${Date.now()}` + renameSync(file, backup) + return { backup } +} diff --git a/packages/opencode/src/permission/alpha-tool-policy.ts b/packages/opencode/src/permission/alpha-tool-policy.ts new file mode 100644 index 000000000000..fd11f5ef2c42 --- /dev/null +++ b/packages/opencode/src/permission/alpha-tool-policy.ts @@ -0,0 +1,294 @@ +// alpha 自有文件(basename `alpha-*`;ADR-043 谓词因子②)。 +// +// REQ-131 / #1128 —— 分层工具策略的**三态 resolver**(#724 CLOSE_DECIDE §2/§3/§4/§5)。 +// +// 合成顺序(§4,第一版的 `deny > ask > allow` 排序已被否决,这里是终局语义): +// 1. 先解析**不可突破的 cap**:managed deny、服务端 entitlement 缺失/deny、 +// 现有 sovereignty/kill-switch deny。任一命中即 disabled。 +// 2. 再解析当前账户+工作区的**用户 selector**(exact tool > service > class)。 +// 用户 disabled 不可被 session grant 撬开;用户 enabled 可以替换 class 默认 ask, +// 但不能覆盖第 1 步 cap。service/tool 的 enabled 还要过 binding guard —— +// binding 变了(rebind / 重装)回到 ask,不沿用旧授权。 +// 3. 无 deny 时,仍适用的 ask 表示「本次调用待批准」,交给现有 Permission 引擎。 +// 4. `once` / `always` 的会话语义在 Permission 引擎里(`./index.ts`):always 仅在 +// 当前 sessionID 内保存,只能 discharge ask,永远压不过任何 deny。 +// +// 本 resolver 是 #1129(目录与执行咽喉)与 #1130(Settings)要消费的那份 API; +// 本票不接线咽喉,也不建 UI。 +import path from "path" +import { Global } from "@opencode-ai/core/global" +import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer, Schema } from "effect" +import { canonicalToolIdentity } from "@opencode-ai/schema/tool-identity" +import { + classDefaultState, + classifyTool, + selectorKey, + selectorMatches, + selectorSpecificity, + toPermissionAction, + type ToolClass, + type ToolPolicyPartition, + type ToolPolicyRecord, + type ToolPolicySelector, + type ToolPolicyState, + type ToolPolicySubject, +} from "@opencode-ai/schema/alpha-tool-policy" +import { InstanceState } from "@/effect/instance-state" +import { managedCapDenies, readManagedPolicy, type ManagedPolicyResult } from "./alpha-managed-policy" +import { + canonicalJsonDigest, + loadPolicyDocument, + policyFilePath, + resetPolicyDocument, + savePolicyDocument, + type PolicyLoadResult, +} from "./alpha-tool-policy-store" + +// ── binding digest 派生(§5)──────────────────────────────────────────────────── +// Alpha Cloud:直接复用 verified `authority.evidenceDigest`,不再派生第二个。 +// 第三方 MCP:对**去秘密后的**有效 server definition 派生 —— headers / environment / +// oauth 里住着 token/secret,既不该进 digest 也不该因轮换而判 rebind; +// enabled/timeout 是运维参数,不是 binding。url(remote)/ command+cwd(local) +// 变了才是 rebind,旧的 service/tool enabled 必须失效回 ask。 +export function mcpBindingEvidence(name: string, entry: ConfigMCPV1.Info): unknown { + if (entry.type === "remote") return { kind: "mcp-remote", name, url: entry.url } + return { kind: "mcp-local", name, command: entry.command, cwd: entry.cwd ?? null } +} + +export function mcpBindingDigest(name: string, entry: ConfigMCPV1.Info): string { + return canonicalJsonDigest(mcpBindingEvidence(name, entry)) +} + +/** plugin / 其它宿主派生 binding 证据的通用入口(安装 receipt / manifest / loader generation)。 */ +export function deriveBindingDigest(evidence: unknown): string { + return canonicalJsonDigest(evidence) +} + +/** 主体当前 binding:Alpha Cloud 用 authority 证据,其余用调用方派生的 digest。 */ +export function subjectBindingDigest(subject: ToolPolicySubject): string | undefined { + if (subject.authority.kind === "alpha-cloud") return subject.authority.evidenceDigest + return subject.bindingDigest +} + +// ── effective policy ───────────────────────────────────────────────────────── +export type EffectiveToolPolicyReason = + | { kind: "invalid-identity"; detail: string } + | { kind: "cap-managed" } + | { kind: "cap-managed-unreadable"; detail: string } + | { kind: "cap-entitlement"; verdict: "deny" | "missing" } + | { kind: "cap-hard-deny"; sources: readonly string[] } + | { kind: "quarantine"; detail: string } + | { kind: "user"; level: "class" | "service" | "tool" } + | { kind: "binding-changed"; level: "service" | "tool" } + | { kind: "default"; class: ToolClass } + +export interface EffectiveToolPolicy { + readonly state: ToolPolicyState + /** 编译到现有 Permission 引擎的动作(§2:不另造第二个审批引擎)。 */ + readonly action: "allow" | "ask" | "deny" + readonly reason: EffectiveToolPolicyReason +} + +function effective(state: ToolPolicyState, reason: EffectiveToolPolicyReason): EffectiveToolPolicy { + return { state, action: toPermissionAction(state), reason } +} + +export interface ToolPolicyCaps { + readonly managed: ManagedPolicyResult + /** 服务端 entitlement 判定(§4 cap;由调用方在可得时传入 —— 本地永远造不出服务端 allow)。 */ + readonly entitlement?: "allow" | "deny" | "missing" + /** 现有 sovereignty / kill-switch deny 的来源名单;非空即 cap 命中(只取交集,不替换)。 */ + readonly hardDeny?: readonly string[] +} + +export type ToolPolicyUserLayer = + | { status: "ok"; records: readonly ToolPolicyRecord[] } + | { status: "absent" } + | { status: "quarantined"; reason: string } + +/** + * 纯合成核(§4)。输入即全部事实,无 IO —— 让「一个错误实现能不能满足断言」 + * 在每一层都可单独证伪。 + */ +export function resolveToolPolicy(input: { + readonly subject: ToolPolicySubject + readonly caps: ToolPolicyCaps + readonly user: ToolPolicyUserLayer +}): EffectiveToolPolicy { + // 0. identity 缺失/非法/分类矛盾 ⇒ disabled,不广告、loud fail(§2 第五行)。 + let canonical: string + try { + canonical = canonicalToolIdentity(input.subject.identity) + } catch (error) { + return effective("disabled", { + kind: "invalid-identity", + detail: error instanceof Error ? error.message : String(error), + }) + } + const cls = classifyTool(input.subject) + if (cls === undefined) return effective("disabled", { kind: "invalid-identity", detail: "unclassifiable subject" }) + + // 1. cap:任一命中即 disabled,任何下层(用户 enabled、session always)都撬不开。 + if (input.caps.managed.status === "unreadable") + return effective("disabled", { kind: "cap-managed-unreadable", detail: input.caps.managed.reason }) + if (managedCapDenies(canonical, input.caps.managed.ruleset)) return effective("disabled", { kind: "cap-managed" }) + if (input.caps.entitlement === "deny" || input.caps.entitlement === "missing") + return effective("disabled", { kind: "cap-entitlement", verdict: input.caps.entitlement }) + if (input.caps.hardDeny !== undefined && input.caps.hardDeny.length > 0) + return effective("disabled", { kind: "cap-hard-deny", sources: input.caps.hardDeny }) + + // 2. 用户层。quarantine ⇒ 所有用户可配置工具 disabled(保留上面的 cap 判定)。 + if (input.user.status === "quarantined") + return effective("disabled", { kind: "quarantine", detail: input.user.reason }) + if (input.user.status === "ok") { + const matches = input.user.records.filter((record) => selectorMatches(record.selector, input.subject)) + if (matches.length > 0) { + const best = Math.max(...matches.map((record) => selectorSpecificity(record.selector))) + const chosen = matches.filter((record) => selectorSpecificity(record.selector) === best) + // 同层撞出两条都匹配的记录 ⇒ 它们必然是同一 selector 的重复(class/service/tool 对 + // 单一主体各自最多命中一条)—— 与 store 的唯一性闸同一条纪律:冲突即坏,fail-closed。 + if (chosen.length > 1) + return effective("disabled", { + kind: "quarantine", + detail: `conflicting policy records for ${selectorKey(chosen[0]!.selector)}`, + }) + const record = chosen[0]! + const level = record.selector.level + if (record.state === "disabled") return effective("disabled", { kind: "user", level }) + if (record.state === "ask") return effective("ask", { kind: "user", level }) + // enabled:class 是 broad intent(§3),service/tool 必须过 binding guard(§5)。 + if (level === "class") return effective("enabled", { kind: "user", level }) + const current = subjectBindingDigest(input.subject) + if (current === undefined || current !== record.bindingDigest) + return effective("ask", { kind: "binding-changed", level }) + return effective("enabled", { kind: "user", level }) + } + } + + // 3. 四类默认(§2):本地 enabled,其余 ask;「新发现」工具吃默认。 + return effective(classDefaultState(cls), { kind: "default", class: cls }) +} + +// ── Effect service(#1129/#1130 消费的 API)──────────────────────────────────── +export class ToolPolicyWriteError extends Schema.TaggedErrorClass()( + "ToolPolicyWriteError", + { message: Schema.String }, +) {} + +export interface ResolveCapsInput { + readonly entitlement?: "allow" | "deny" | "missing" + readonly hardDeny?: readonly string[] +} + +export interface Interface { + /** 每次调用重读当前 cap 与用户文档(§6:executor 必须在调用时重读,不缓存旧对象)。 */ + readonly resolve: ( + subject: ToolPolicySubject, + caps?: ResolveCapsInput, + ) => Effect.Effect + readonly inspect: () => Effect.Effect<{ partition: ToolPolicyPartition; user: PolicyLoadResult }> + readonly setRecord: (record: ToolPolicyRecord) => Effect.Effect + readonly removeRecord: (selector: ToolPolicySelector) => Effect.Effect + readonly reset: () => Effect.Effect<{ backup?: string }> +} + +export class Service extends Context.Service()("@opencode/AlphaToolPolicy") {} + +export interface LayerOptions { + /** 测试注入口(生产零参):策略文档根目录。 */ + readonly baseDir?: string + /** 测试注入口:账户 subject。生产默认 anonymous —— 引擎侧今天没有账户权威,#1129/#1130 接线。 */ + readonly account?: Effect.Effect + /** 测试注入口:managed cap 读取(生产 = `readManagedPolicy()`,系统目录不可被 env 替换)。 */ + readonly managed?: () => Promise +} + +export const layer = (options?: LayerOptions) => + Layer.effect( + Service, + Effect.gen(function* () { + const baseDir = options?.baseDir ?? path.join(Global.Path.data, "alpha-tool-policy") + const accountEffect = options?.account ?? Effect.succeed("anonymous") + const managedRead = options?.managed ?? (() => readManagedPolicy()) + + const partition = Effect.gen(function* () { + const account = yield* accountEffect + const ctx = yield* InstanceState.context + return { account, workspace: String(ctx.project.id) } + }) + + 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 } + return resolveToolPolicy({ + subject, + caps: { managed, entitlement: caps?.entitlement, hardDeny: caps?.hardDeny }, + user, + }) + }) + + const inspect = Effect.fn("AlphaToolPolicy.inspect")(function* () { + const part = yield* partition + return { partition: part, user: loadPolicyDocument(baseDir, part) } + }) + + const mutate = ( + description: string, + change: (records: readonly ToolPolicyRecord[]) => readonly ToolPolicyRecord[], + ) => + Effect.gen(function* () { + const part = yield* partition + const loaded = loadPolicyDocument(baseDir, part) + if (loaded.status === "quarantined") + return yield* new ToolPolicyWriteError({ + message: `refusing to ${description}: policy document is quarantined (${loaded.reason}); reset to defaults first`, + }) + const records = loaded.status === "ok" ? loaded.doc.records : [] + yield* Effect.try({ + try: () => savePolicyDocument(baseDir, part, change(records)), + catch: (error) => + new ToolPolicyWriteError({ + message: error instanceof Error ? error.message : String(error), + }), + }) + }) + + const setRecord = Effect.fn("AlphaToolPolicy.setRecord")(function* (record: ToolPolicyRecord) { + yield* mutate("write a record", (records) => [ + ...records.filter((item) => selectorKey(item.selector) !== selectorKey(record.selector)), + record, + ]) + }) + + const removeRecord = Effect.fn("AlphaToolPolicy.removeRecord")(function* (selector: ToolPolicySelector) { + yield* mutate("remove a record", (records) => + records.filter((item) => selectorKey(item.selector) !== selectorKey(selector)), + ) + }) + + const reset = Effect.fn("AlphaToolPolicy.reset")(function* () { + const part = yield* partition + return resetPolicyDocument(baseDir, part) + }) + + return Service.of({ resolve, inspect, setRecord, removeRecord, reset }) + }), + ) + +export const node = LayerNode.make({ service: Service, layer: layer(), deps: [] }) + +export { policyFilePath, type PolicyLoadResult, type ManagedPolicyResult } + +export * as AlphaToolPolicy from "./alpha-tool-policy" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 48bf3bb2e2ef..c9dc8dcc39d9 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -70,6 +70,11 @@ export interface Interface { readonly ask: (input: PermissionV1.AskInput) => Effect.Effect readonly reply: (input: PermissionV1.ReplyInput) => Effect.Effect readonly list: () => Effect.Effect> + /** + * 清空 session grant(#724 §5:切账户/登出必须清 session grant)。不带参数清全部; + * 带 `sessionID` 只清该会话。#1129/#1130 在账户切换/登出路径上接线。 + */ + readonly clearGrants: (input?: { sessionID?: string }) => Effect.Effect } interface PendingEntry { @@ -77,9 +82,23 @@ interface PendingEntry { deferred: Deferred.Deferred } +/** + * `always` 产生的 **session grant**(#1128 / #724 §4.4)。它不是一条规则: + * - 只在 `sessionID + permission + resource pattern` 内成立 —— 换会话必须重新评估 + * (#1122 B13 的根因正是旧 `approved: Rule[]` 不带 sessionID,instance 级串扰); + * - 只能 **discharge 一个 ask**,永远不参与与 deny 的竞争 —— 旧实现把它混进 + * `[...ruleset, ...approved]` 的 findLast,approved 排在后面赢过任何 deny + * (#1122 B9)。现在 deny/allow 只由 ruleset 决定,grant 只在 ask 态被查询。 + */ +interface SessionGrant { + sessionID: string + permission: string + pattern: string +} + interface State { pending: Map - approved: PermissionV1.Rule[] + granted: SessionGrant[] } export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule { @@ -94,6 +113,21 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Permi ) } +/** grant 对 (session, permission, pattern) 的匹配 —— 与 evaluate 的规则匹配同一套 Wildcard 语义。 */ +function grantDischarges( + granted: readonly SessionGrant[], + sessionID: string, + permission: string, + pattern: string, +): boolean { + return granted.some( + (grant) => + grant.sessionID === sessionID && + Wildcard.match(permission, grant.permission) && + Wildcard.match(pattern, grant.pattern), + ) +} + export class Service extends Context.Service()("@opencode/Permission") {} const layer = Layer.effect( @@ -103,9 +137,9 @@ const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { void ctx - const state = { + const state: State = { pending: new Map(), - approved: [], + granted: [], } yield* Effect.addFinalizer(() => @@ -122,12 +156,15 @@ const layer = Layer.effect( ) const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { - const { approved, pending } = yield* InstanceState.get(state) + const { granted, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false + // #1128 / #724 §4:deny 与 allow 只由 ruleset 决定;session grant 不参与这一步 —— + // 它只能在 ask 态 discharge。旧实现把 approved 拼在 ruleset 之后取 findLast, + // 一张旧批条赢过后来收紧的 deny(#1122 B9);现在 deny 结构性不可被 grant 突破。 for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, approved) + const rule = evaluate(request.permission, pattern, ruleset) yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new PermissionV1.DeniedError({ @@ -135,6 +172,7 @@ const layer = Layer.effect( }) } if (rule.action === "allow") continue + if (grantDischarges(granted, request.sessionID, request.permission, pattern)) continue needsAsk = true } @@ -190,7 +228,7 @@ const layer = Layer.effect( }) const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { - const { approved, pending } = yield* InstanceState.get(state) + const { granted, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) @@ -225,18 +263,20 @@ const layer = Layer.effect( yield* Deferred.succeed(existing.deferred, undefined) if (input.reply === "once") return + // `always` 仅在当前 sessionID 内保存(#724 §4.4)。换会话 / 换账户工作区都要 + // 重新评估(#1122 B13)—— grant 带着 sessionID 落账,别的会话查不到它。 for (const pattern of existing.info.always) { - approved.push({ + granted.push({ + sessionID: existing.info.sessionID, permission: existing.info.permission, pattern, - action: "allow", }) } for (const [id, item] of pending.entries()) { if (item.info.sessionID !== existing.info.sessionID) continue - const ok = item.info.patterns.every( - (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", + const ok = item.info.patterns.every((pattern) => + grantDischarges(granted, item.info.sessionID, item.info.permission, pattern), ) if (!ok) continue pending.delete(id) @@ -254,7 +294,13 @@ const layer = Layer.effect( return Array.from(pending.values(), (item) => item.info) }) - return Service.of({ ask, reply, list }) + const clearGrants = Effect.fn("Permission.clearGrants")(function* (input?: { sessionID?: string }) { + const s = yield* InstanceState.get(state) + s.granted = + input?.sessionID === undefined ? [] : s.granted.filter((grant) => grant.sessionID !== input.sessionID) + }) + + return Service.of({ ask, reply, list, clearGrants }) }), ) diff --git a/packages/opencode/test/permission/alpha-session-grants.test.ts b/packages/opencode/test/permission/alpha-session-grants.test.ts new file mode 100644 index 000000000000..be942b92658c --- /dev/null +++ b/packages/opencode/test/permission/alpha-session-grants.test.ts @@ -0,0 +1,199 @@ +// #1128(REQ-131 CODE)—— v1 Permission 引擎的 session grant 语义闸(#724 §4.4/§4.5;#1122 B9/B13)。 +// +// 判据全部走**生产引擎**:真实挂载 `Permission.node + EventV2Bridge.node + InstanceStore.node`, +// 调生产 `permission.ask` / `permission.reply`(与 `session/tools.ts` 给工具的 `ctx.ask` +// 同一个服务、同一个方法)。不测内层纯函数 —— 落在合成层的绕过必须在这里翻红。 +// +// 钉住的保证(每一条都问过「一个错误实现能不能满足它」): +// ① deny 不可被 session grant 突破(B9):先 always 再收紧成 deny,ask 必须以 +// DeniedError 失败 —— 断言的是**失败类型**,不是布尔;把 grant 拼回 ruleset 之后 +// 取 findLast 的旧实现在此当场红。 +// ② always 只在当前 session 生效(B13):A 会话的批条对 B 会话**不可见** —— +// B 的同 subject ask 必须真的进 pending(以 `list()` 里出现 B 的请求为证), +// 而不是「没弹窗」(把 ask 整段删掉也没弹窗)。 +// ③ always discharge ask(#1128 退出条件):同 session 同 subject 第二次 ask +// 直接返回,pending 全程为空 —— 断言「从没进过待批队列」,不是「最终没挂着」。 +// ④ once 不落账:同 subject 第二次必须重新进 pending。 +// ⑤ `clearGrants`(§5 切账户/登出的清账口):清了指定会话的批条之后必须重新问; +// 别的会话的批条不受影响。 +// +// 变异验证(交付时实跑,结论写进 PR): +// · 把 ask 里的 `evaluate(request.permission, pattern, ruleset)` 换回 +// `evaluate(..., ruleset, grantsAsRules)` ⇒ ①红; +// · 把 grant 的 `sessionID` 匹配去掉(退回 instance 级)⇒ ②⑤红。 +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { expect, test } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Permission } from "../../src/permission" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { SessionID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" + +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const env = AppNodeBuilder.build( + LayerNode.group([Permission.node, EventV2Bridge.node, CrossSpawnSpawner.node, InstanceStore.node]), + [[InstanceStore.bootstrapNode, noopBootstrap]], +) +const it = testEffect(env) + +/** 期望值手写字面量 —— 不从 canonicalToolIdentity 导出(自指等价链)。 */ +const SUBJECT = "mcp:policy:paid_action" + +const ASK_RULESET: PermissionV1.Rule[] = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: SUBJECT, pattern: "*", action: "ask" }, +] +const DENY_RULESET: PermissionV1.Rule[] = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: SUBJECT, pattern: "*", action: "deny" }, +] + +const askSubject = (sessionID: string, ruleset: PermissionV1.Rule[]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask({ + sessionID: SessionID.make(sessionID), + permission: SUBJECT, + patterns: ["*"], + metadata: {}, + always: ["*"], + ruleset, + }) + }) + +/** 轮询生产 `Permission.list()` 直到出现待批请求(审批面读的就是它)。 */ +const awaitPending = Effect.fn("alphaGrants.awaitPending")(function* (rounds = 100) { + 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("10 millis") + } + return yield* permission.list() +}) + +/** 走完一轮「ask → 进 pending → 用户应答」,返回 ask fiber 的 Exit。 */ +const askAndReply = (sessionID: string, ruleset: PermissionV1.Rule[], reply: PermissionV1.Reply) => + Effect.gen(function* () { + const permission = yield* Permission.Service + const fiber = yield* askSubject(sessionID, ruleset).pipe(Effect.forkScoped) + const pending = yield* awaitPending() + expect(pending.length).toBe(1) + expect(pending[0]!.permission).toBe(SUBJECT) + yield* permission.reply({ requestID: pending[0]!.id, reply }) + return yield* Fiber.await(fiber) + }) + +it.instance( + "① B9:always 之后把同一 subject 收紧成 deny ⇒ ask 以 DeniedError 失败,批条压不过 deny", + () => + Effect.gen(function* () { + const first = yield* askAndReply("session_b9", ASK_RULESET, "always") + expect(Exit.isSuccess(first)).toBe(true) + + const denied = yield* askSubject("session_b9", DENY_RULESET).pipe(Effect.exit) + expect(Exit.isFailure(denied)).toBe(true) + if (!Exit.isFailure(denied)) return + // 失败**类型**必须是 DeniedError —— 「ok 是 false」不够(reason 换了也是 false)。 + expect(Cause.squash(denied.cause)).toBeInstanceOf(PermissionV1.DeniedError) + // deny 判定后不得残留 pending(deny 不是「挂起待批」)。 + const permission = yield* Permission.Service + expect(yield* permission.list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "② B13:会话 A 点 always ⇒ 会话 B 同 subject 必须重新进待批队列,批条不跨会话", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + const first = yield* askAndReply("session_b13_a", ASK_RULESET, "always") + expect(Exit.isSuccess(first)).toBe(true) + + const fiberB = yield* askSubject("session_b13_b", ASK_RULESET).pipe(Effect.forkScoped) + const pendingB = yield* awaitPending() + // 判据是「B 的请求真的出现在待批队列」,不是「没直接放行」。 + expect(pendingB.map((item) => item.sessionID)).toContain(SessionID.make("session_b13_b")) + yield* permission.reply({ requestID: pendingB[0]!.id, reply: "reject" }) + const exitB = yield* Fiber.await(fiberB) + expect(Exit.isFailure(exitB)).toBe(true) + }), + { git: true }, +) + +it.instance( + "③ always discharge ask:同 session 同 subject 第二次直接返回,从没进过待批队列", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + const first = yield* askAndReply("session_discharge", ASK_RULESET, "always") + expect(Exit.isSuccess(first)).toBe(true) + + // 第二次:同步完成(不 fork —— 若引擎又去问,这里会挂到测试超时而红)。 + yield* askSubject("session_discharge", ASK_RULESET) + expect(yield* permission.list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "④ once 不落账:同 subject 第二次必须重新进待批队列", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + const first = yield* askAndReply("session_once", ASK_RULESET, "once") + expect(Exit.isSuccess(first)).toBe(true) + + const second = yield* askSubject("session_once", ASK_RULESET).pipe(Effect.forkScoped) + const pending = yield* awaitPending() + expect(pending.length).toBe(1) + expect(pending[0]!.sessionID).toBe(SessionID.make("session_once")) + yield* permission.reply({ requestID: pending[0]!.id, reply: "reject" }) + const exit = yield* Fiber.await(second) + expect(Exit.isFailure(exit)).toBe(true) + }), + { git: true }, +) + +it.instance( + "⑤ clearGrants:清掉本会话批条后必须重新问;只清指定会话,别的会话批条仍在", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + expect(Exit.isSuccess(yield* askAndReply("session_clear_x", ASK_RULESET, "always"))).toBe(true) + expect(Exit.isSuccess(yield* askAndReply("session_clear_y", ASK_RULESET, "always"))).toBe(true) + + yield* permission.clearGrants({ sessionID: "session_clear_x" }) + + // x 的批条没了 ⇒ 重新进队列。 + const fiberX = yield* askSubject("session_clear_x", ASK_RULESET).pipe(Effect.forkScoped) + const pendingX = yield* awaitPending() + expect(pendingX.map((item) => item.sessionID)).toContain(SessionID.make("session_clear_x")) + yield* permission.reply({ requestID: pendingX[0]!.id, reply: "reject" }) + yield* Fiber.await(fiberX) + + // y 的批条仍在 ⇒ 直接放行、不进队列。 + yield* askSubject("session_clear_y", ASK_RULESET) + expect(yield* permission.list()).toHaveLength(0) + + // 全清 ⇒ y 也要重新问。 + yield* permission.clearGrants() + const fiberY = yield* askSubject("session_clear_y", ASK_RULESET).pipe(Effect.forkScoped) + const pendingY = yield* awaitPending() + expect(pendingY.map((item) => item.sessionID)).toContain(SessionID.make("session_clear_y")) + yield* permission.reply({ requestID: pendingY[0]!.id, reply: "reject" }) + yield* Fiber.await(fiberY) + }), + { git: true }, +) + +test("SUBJECT 字面量与生产 canonical 编码一致(独立锚点核对,不反向导出期望值)", async () => { + const { canonicalToolIdentity } = await import("@opencode-ai/schema/tool-identity") + expect(canonicalToolIdentity({ source: "mcp", origin: "policy", name: "paid_action" })).toBe(SUBJECT) +}) diff --git a/packages/opencode/test/permission/alpha-tool-policy.test.ts b/packages/opencode/test/permission/alpha-tool-policy.test.ts new file mode 100644 index 000000000000..717db3bd4928 --- /dev/null +++ b/packages/opencode/test/permission/alpha-tool-policy.test.ts @@ -0,0 +1,543 @@ +// #1128(REQ-131 CODE)—— 分层工具策略 resolver / selector / 持久化 / managed cap 的判据 +// (#724 CLOSE_DECIDE §2/§3/§4/§5 + 本票必修的负向闸)。 +// +// 锚点纪律:期望值一律手写字面量(canonical 串、路径、默认态),不从被测模块反向导出; +// 唯一的例外是文件末的「锚点核对」用例,它反向核对字面量没有抄错,方向与生产相反。 +// +// 必修负向闸(M 组):`OPENCODE_TEST_MANAGED_CONFIG_DIR` **存在时**,系统 managed 目录的 +// deny 仍然生效 —— env 只能 additive(加规则),永远压不掉、替不掉系统目录。 +// 变异验证(交付时实跑,结论写进 PR):把 `readManagedPolicy` 的系统目录读取改回上游 +// `managedConfigDir()`(env 替换语义)⇒ M1/M4 当场红。 +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { + classDefaultState, + classifyTool, + parseToolPolicyDocument, + selectorKey, + selectorMatches, + toPermissionAction, + type ToolPolicyRecord, + type ToolPolicySubject, +} from "@opencode-ai/schema/alpha-tool-policy" +import { AlphaToolPolicy } from "../../src/permission/alpha-tool-policy" +import { managedCapDenies, readManagedPolicy, systemManagedPolicyDir } from "../../src/permission/alpha-managed-policy" +import { + loadPolicyDocument, + policyFilePath, + resetPolicyDocument, + savePolicyDocument, +} from "../../src/permission/alpha-tool-policy-store" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { testEffect } from "../lib/effect" + +// ── 手写字面量锚点 ─────────────────────────────────────────────────────────── +const ID_MCP_PAID = "mcp:policy:paid_action" +const DIGEST_A = `sha256:${"a".repeat(64)}` +const DIGEST_B = `sha256:${"b".repeat(64)}` + +const mcpSubject = (bindingDigest?: string): ToolPolicySubject => ({ + identity: { source: "mcp", origin: "policy", name: "paid_action" }, + authority: { kind: "not-asserted" }, + bindingDigest, +}) + +const cloudSubject = (evidenceDigest: string): ToolPolicySubject => ({ + identity: { source: "mcp", origin: "alpha", name: "web_search" }, + authority: { kind: "alpha-cloud", bindingId: "mcp:alpha", evidenceDigest }, +}) + +const NO_CAPS = { managed: { status: "ok", ruleset: [], sources: [] } } as const +const NO_USER = { status: "absent" } as const + +const tmpdirs: string[] = [] +function tmpdir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + tmpdirs.push(dir) + return dir +} +afterEach(() => { + while (tmpdirs.length > 0) fs.rmSync(tmpdirs.pop()!, { recursive: true, force: true }) +}) + +const PARTITION = { account: "acct-1", workspace: "ws-1" } + +// ═══ S. 分类与 selector(§2/§3)═══════════════════════════════════════════════ +describe("classify + selector matcher", () => { + test("S1 四类分类只认 identity.source 与 verified authority", () => { + expect(classifyTool({ identity: { source: "builtin", origin: "", name: "write" }, authority: { kind: "not-asserted" } })).toBe("builtin") + expect(classifyTool({ identity: { source: "builtin-v2", origin: "", name: "write" }, authority: { kind: "not-asserted" } })).toBe("builtin") + expect(classifyTool({ identity: { source: "host", origin: "", name: "list_mcp_resources" }, authority: { kind: "not-asserted" } })).toBe("builtin") + expect(classifyTool(cloudSubject(DIGEST_A))).toBe("alpha-cloud") + expect(classifyTool(mcpSubject())).toBe("third-party-mcp") + expect(classifyTool({ identity: { source: "plugin", origin: "probe", name: "default" }, authority: { kind: "not-asserted" } })).toBe("plugin") + // identity 铸不出 canonical(空 name)⇒ 无类可归。 + expect(classifyTool({ identity: { source: "mcp", origin: "x", name: "" }, authority: { kind: "not-asserted" } })).toBeUndefined() + expect(classDefaultState("builtin")).toBe("enabled") + expect(classDefaultState("alpha-cloud")).toBe("ask") + expect(classDefaultState("third-party-mcp")).toBe("ask") + expect(classDefaultState("plugin")).toBe("ask") + }) + + test("S2 手拼 wildcard 不是一个层级:`mcp:policy:*` 被 schema 拒绝;name=`*` 只匹配那个字面工具", () => { + // 非规范形(裸 `*` 应编码为 %2A)⇒ decode loud fail,不落成一条永不命中的死记录。 + expect(() => + parseToolPolicyDocument({ + version: 1, + partition: PARTITION, + records: [{ selector: { level: "tool", canonical: "mcp:policy:*" }, state: "disabled" }], + }), + ).toThrow() + // 真有个名字叫 `*` 的工具:canonical 是 mcp:policy:%2A,只匹配它自己。 + const starTool: ToolPolicySubject = { + identity: { source: "mcp", origin: "policy", name: "*" }, + authority: { kind: "not-asserted" }, + } + const starSelector = { level: "tool", canonical: "mcp:policy:%2A" } as const + expect(selectorMatches(starSelector, starTool)).toBe(true) + expect(selectorMatches(starSelector, mcpSubject())).toBe(false) + }) + + test("S3 service selector 结构匹配一台 server 的全部工具,不匹配别台", () => { + const service = { level: "service", source: "mcp", origin: "policy" } as const + expect(selectorMatches(service, mcpSubject())).toBe(true) + expect( + selectorMatches(service, { + identity: { source: "mcp", origin: "policy", name: "free_action" }, + authority: { kind: "not-asserted" }, + }), + ).toBe(true) + expect( + selectorMatches(service, { + identity: { source: "mcp", origin: "other", name: "paid_action" }, + authority: { kind: "not-asserted" }, + }), + ).toBe(false) + expect(toPermissionAction("enabled")).toBe("allow") + expect(toPermissionAction("ask")).toBe("ask") + expect(toPermissionAction("disabled")).toBe("deny") + }) +}) + +// ═══ R. 纯合成核(§4)═══════════════════════════════════════════════════════ +describe("resolveToolPolicy", () => { + const resolve = AlphaToolPolicy.resolveToolPolicy + + test("R1 默认:本地 enabled;Alpha Cloud / 第三方 MCP / plugin 一律 ask", () => { + const builtin = resolve({ + subject: { identity: { source: "builtin", origin: "", name: "write" }, authority: { kind: "not-asserted" } }, + caps: NO_CAPS, + user: NO_USER, + }) + expect(builtin).toEqual({ state: "enabled", action: "allow", reason: { kind: "default", class: "builtin" } }) + expect(resolve({ subject: cloudSubject(DIGEST_A), caps: NO_CAPS, user: NO_USER }).state).toBe("ask") + expect(resolve({ subject: mcpSubject(), caps: NO_CAPS, user: NO_USER }).state).toBe("ask") + expect( + resolve({ + subject: { identity: { source: "plugin", origin: "probe", name: "default" }, authority: { kind: "not-asserted" } }, + caps: NO_CAPS, + user: NO_USER, + }).state, + ).toBe("ask") + }) + + test("R2 同层优先级:exact tool > service > class(§3)", () => { + const classRec: ToolPolicyRecord = { selector: { level: "class", class: "third-party-mcp" }, state: "disabled" } + const serviceRec: ToolPolicyRecord = { + selector: { level: "service", source: "mcp", origin: "policy" }, + state: "enabled", + bindingDigest: DIGEST_A, + } + const toolRec: ToolPolicyRecord = { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "ask" } + const subject = mcpSubject(DIGEST_A) + const all = resolve({ subject, caps: NO_CAPS, user: { status: "ok", records: [classRec, serviceRec, toolRec] } }) + expect(all).toEqual({ state: "ask", action: "ask", reason: { kind: "user", level: "tool" } }) + const noTool = resolve({ subject, caps: NO_CAPS, user: { status: "ok", records: [classRec, serviceRec] } }) + expect(noTool).toEqual({ state: "enabled", action: "allow", reason: { kind: "user", level: "service" } }) + const onlyClass = resolve({ subject, caps: NO_CAPS, user: { status: "ok", records: [classRec] } }) + expect(onlyClass).toEqual({ state: "disabled", action: "deny", reason: { kind: "user", level: "class" } }) + }) + + test("R3 managed cap deny 压过用户 enabled(cap 在第 1 步,§4)", () => { + const result = resolve({ + subject: mcpSubject(DIGEST_A), + caps: { managed: { status: "ok", ruleset: [{ permission: ID_MCP_PAID, pattern: "*", action: "deny" }], sources: ["managed"] } }, + user: { + status: "ok", + records: [{ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "enabled", bindingDigest: DIGEST_A }], + }, + }) + expect(result).toEqual({ state: "disabled", action: "deny", reason: { kind: "cap-managed" } }) + }) + + test("R4 entitlement 缺失/deny 与 sovereignty/kill-switch deny 都是 cap ⇒ disabled", () => { + expect(resolve({ subject: cloudSubject(DIGEST_A), caps: { ...NO_CAPS, entitlement: "missing" }, user: NO_USER })) + .toEqual({ state: "disabled", action: "deny", reason: { kind: "cap-entitlement", verdict: "missing" } }) + expect(resolve({ subject: cloudSubject(DIGEST_A), caps: { ...NO_CAPS, entitlement: "deny" }, user: NO_USER }).state).toBe("disabled") + expect( + resolve({ subject: mcpSubject(), caps: { ...NO_CAPS, hardDeny: ["web-search-kill-switch"] }, user: NO_USER }), + ).toEqual({ state: "disabled", action: "deny", reason: { kind: "cap-hard-deny", sources: ["web-search-kill-switch"] } }) + // entitlement allow 只是必要条件,不产生 enabled:默认仍是 ask。 + expect(resolve({ subject: cloudSubject(DIGEST_A), caps: { ...NO_CAPS, entitlement: "allow" }, user: NO_USER }).state).toBe("ask") + }) + + test("R6 binding guard:service/tool enabled 只在 digest 逐字相等时生效,变了/缺了回 ask(§5)", () => { + const record: ToolPolicyRecord = { + selector: { level: "service", source: "mcp", origin: "policy" }, + state: "enabled", + bindingDigest: DIGEST_A, + } + const user = { status: "ok", records: [record] } as const + expect(resolve({ subject: mcpSubject(DIGEST_A), caps: NO_CAPS, user }).state).toBe("enabled") + expect(resolve({ subject: mcpSubject(DIGEST_B), caps: NO_CAPS, user })).toEqual({ + state: "ask", + action: "ask", + reason: { kind: "binding-changed", level: "service" }, + }) + // 当前 binding 不可得 ⇒ 不放行(fail-closed),回 ask。 + expect(resolve({ subject: mcpSubject(undefined), caps: NO_CAPS, user }).state).toBe("ask") + // disabled 不因 rebind 失效:收紧是安全方向。 + const disabledRec: ToolPolicyRecord = { selector: { level: "service", source: "mcp", origin: "policy" }, state: "disabled" } + expect(resolve({ subject: mcpSubject(DIGEST_B), caps: NO_CAPS, user: { status: "ok", records: [disabledRec] } }).state).toBe("disabled") + }) + + test("R7 Alpha Cloud 的 binding 就是 verified evidenceDigest:换了证据 ⇒ 回 ask", () => { + const record: ToolPolicyRecord = { + selector: { level: "tool", canonical: "mcp:alpha:web_search" }, + state: "enabled", + bindingDigest: DIGEST_A, + } + const user = { status: "ok", records: [record] } as const + expect(resolve({ subject: cloudSubject(DIGEST_A), caps: NO_CAPS, user }).state).toBe("enabled") + expect(resolve({ subject: cloudSubject(DIGEST_B), caps: NO_CAPS, user })).toEqual({ + state: "ask", + action: "ask", + reason: { kind: "binding-changed", level: "tool" }, + }) + }) + + test("R8/R9/R10/R11 fail-closed 族:非法 identity / quarantine / managed unreadable / 冲突记录 ⇒ disabled", () => { + expect( + resolve({ + subject: { identity: { source: "mcp", origin: "x", name: "" }, authority: { kind: "not-asserted" } }, + caps: NO_CAPS, + user: NO_USER, + }).reason.kind, + ).toBe("invalid-identity") + expect( + resolve({ subject: mcpSubject(), caps: NO_CAPS, user: { status: "quarantined", reason: "broken" } }), + ).toEqual({ state: "disabled", action: "deny", reason: { kind: "quarantine", detail: "broken" } }) + expect( + resolve({ + subject: mcpSubject(), + caps: { managed: { status: "unreadable", reason: "corrupt", source: "s" } }, + user: NO_USER, + }).reason.kind, + ).toBe("cap-managed-unreadable") + const dup: ToolPolicyRecord = { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "enabled", bindingDigest: DIGEST_A } + const conflicting = resolve({ + subject: mcpSubject(DIGEST_A), + caps: NO_CAPS, + user: { status: "ok", records: [dup, { ...dup, state: "disabled", bindingDigest: undefined }] }, + }) + expect(conflicting.state).toBe("disabled") + expect(conflicting.reason.kind).toBe("quarantine") + }) +}) + +// ═══ T. versioned 持久化 + quarantine(§5)═══════════════════════════════════ +describe("policy store", () => { + test("T1 roundtrip + 原子写 + 分区文件名不含明文", () => { + const dir = tmpdir("alpha-policy-t1-") + const records: ToolPolicyRecord[] = [ + { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }, + { selector: { level: "class", class: "plugin" }, state: "enabled" }, + ] + savePolicyDocument(dir, PARTITION, records) + const file = policyFilePath(dir, PARTITION) + expect(path.basename(file)).toMatch(/^[0-9a-f]{64}\.json$/) + expect(path.basename(file)).not.toContain("acct-1") + const loaded = loadPolicyDocument(dir, PARTITION) + expect(loaded.status).toBe("ok") + if (loaded.status !== "ok") return + expect(loaded.doc.version).toBe(1) + expect(loaded.doc.records).toEqual(records) + // 没有半截 tmp 残留。 + expect(fs.readdirSync(dir).filter((name) => name.includes(".tmp-"))).toEqual([]) + }) + + test("T2 文件不存在 = 首次使用(absent),不是错误", () => { + expect(loadPolicyDocument(tmpdir("alpha-policy-t2-"), PARTITION)).toEqual({ status: "absent" }) + }) + + test("T3 坏文档整份 quarantine:损坏 JSON / 未知版本 / 部分非法记录 / 分区不符 / selector 重复", () => { + const dir = tmpdir("alpha-policy-t3-") + const file = policyFilePath(dir, PARTITION) + fs.mkdirSync(path.dirname(file), { recursive: true }) + + fs.writeFileSync(file, "{ not json") + expect(loadPolicyDocument(dir, PARTITION).status).toBe("quarantined") + + fs.writeFileSync(file, JSON.stringify({ version: 2, partition: PARTITION, records: [] })) + expect(loadPolicyDocument(dir, PARTITION).status).toBe("quarantined") + + // 一条非法(service enabled 缺 bindingDigest)⇒ 整份坏,不是丢那一条。 + fs.writeFileSync( + file, + JSON.stringify({ + version: 1, + partition: PARTITION, + records: [ + { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }, + { selector: { level: "service", source: "mcp", origin: "policy" }, state: "enabled" }, + ], + }), + ) + expect(loadPolicyDocument(dir, PARTITION).status).toBe("quarantined") + + // 把别的分区的文件拷过来 ⇒ 文档体内分区核不上 ⇒ quarantine。 + const other = { account: "acct-2", workspace: "ws-1" } + savePolicyDocument(dir, other, []) + fs.copyFileSync(policyFilePath(dir, other), file) + const stolen = loadPolicyDocument(dir, PARTITION) + expect(stolen.status).toBe("quarantined") + + fs.writeFileSync( + file, + JSON.stringify({ + version: 1, + partition: PARTITION, + records: [ + { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }, + { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "ask" }, + ], + }), + ) + expect(loadPolicyDocument(dir, PARTITION).status).toBe("quarantined") + }) + + test("T4 reset:坏文件挪去带时间戳备份(可恢复),加载回到 absent(批准默认)", () => { + const dir = tmpdir("alpha-policy-t4-") + const file = policyFilePath(dir, PARTITION) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, "{ broken") + const { backup } = resetPolicyDocument(dir, PARTITION) + expect(backup).toBeDefined() + expect(fs.existsSync(backup!)).toBe(true) + expect(fs.readFileSync(backup!, "utf8")).toBe("{ broken") + expect(loadPolicyDocument(dir, PARTITION)).toEqual({ status: "absent" }) + }) + + test("T5 写入前校验:selector 重复与非法记录在写入者手里 loud fail,不落盘", () => { + const dir = tmpdir("alpha-policy-t5-") + const rec: ToolPolicyRecord = { selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" } + expect(() => savePolicyDocument(dir, PARTITION, [rec, { ...rec, state: "ask" }])).toThrow() + expect(() => + savePolicyDocument(dir, PARTITION, [ + { selector: { level: "service", source: "mcp", origin: "policy" }, state: "enabled" } as ToolPolicyRecord, + ]), + ).toThrow() + expect(loadPolicyDocument(dir, PARTITION)).toEqual({ status: "absent" }) + }) + + test("T6 分区隔离:账户 A 的记录不出现在账户 B / 别的工作区的文档里", () => { + const dir = tmpdir("alpha-policy-t6-") + savePolicyDocument(dir, PARTITION, [{ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "enabled", bindingDigest: DIGEST_A }]) + expect(loadPolicyDocument(dir, { account: "acct-2", workspace: "ws-1" })).toEqual({ status: "absent" }) + expect(loadPolicyDocument(dir, { account: "acct-1", workspace: "ws-2" })).toEqual({ status: "absent" }) + const own = loadPolicyDocument(dir, PARTITION) + expect(own.status).toBe("ok") + }) +}) + +// ═══ M. managed cap 负向闸(本票必修)══════════════════════════════════════════ +describe("managed cap —— env 存在时系统 managed deny 仍生效", () => { + const ENV = "OPENCODE_TEST_MANAGED_CONFIG_DIR" + + function withEnv(value: string, fn: () => Promise): Promise { + const saved = process.env[ENV] + process.env[ENV] = value + return fn().finally(() => { + if (saved === undefined) delete process.env[ENV] + else process.env[ENV] = saved + }) + } + + function writeManaged(dir: string, permission: Record) { + fs.writeFileSync(path.join(dir, "opencode.json"), JSON.stringify({ permission })) + } + + test("M1 env 指向 allow-all 目录,系统目录的 deny 仍然构成 cap ⇒ resolver disabled", async () => { + const systemDir = tmpdir("alpha-managed-system-") + const envDir = tmpdir("alpha-managed-env-") + writeManaged(systemDir, { [ID_MCP_PAID]: "deny" }) + writeManaged(envDir, { "*": "allow", [ID_MCP_PAID]: "allow" }) + await withEnv(envDir, async () => { + const result = await readManagedPolicy({ systemDir, plist: null }) + expect(result.status).toBe("ok") + if (result.status !== "ok") return + // env 的 allow 排在系统规则**之前**(更低优先),findLast 语义下压不掉系统 deny。 + expect(managedCapDenies(ID_MCP_PAID, result.ruleset)).toBe(true) + // 走完整 resolver:用户层甚至写了 enabled,cap 仍然赢。 + const effective = AlphaToolPolicy.resolveToolPolicy({ + subject: mcpSubject(DIGEST_A), + caps: { managed: result }, + user: { + status: "ok", + records: [{ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "enabled", bindingDigest: DIGEST_A }], + }, + }) + expect(effective).toEqual({ state: "disabled", action: "deny", reason: { kind: "cap-managed" } }) + }) + }) + + test("M2 env 目录是 additive 的:它能补上系统没提的 deny(方向只会更严)", async () => { + const systemDir = tmpdir("alpha-managed-system-") + const envDir = tmpdir("alpha-managed-env-") + writeManaged(systemDir, {}) + writeManaged(envDir, { "mcp:extra:tool": "deny" }) + await withEnv(envDir, async () => { + const result = await readManagedPolicy({ systemDir, plist: null }) + expect(result.status).toBe("ok") + if (result.status !== "ok") return + expect(managedCapDenies("mcp:extra:tool", result.ruleset)).toBe(true) + expect(managedCapDenies(ID_MCP_PAID, result.ruleset)).toBe(false) + }) + }) + + test("M3 系统目录显式 allow 时,env 的 deny 不越权覆盖(env 优先级更低)", async () => { + const systemDir = tmpdir("alpha-managed-system-") + const envDir = tmpdir("alpha-managed-env-") + writeManaged(systemDir, { [ID_MCP_PAID]: "allow" }) + writeManaged(envDir, { [ID_MCP_PAID]: "deny" }) + await withEnv(envDir, async () => { + const result = await readManagedPolicy({ systemDir, plist: null }) + expect(result.status).toBe("ok") + if (result.status !== "ok") return + expect(managedCapDenies(ID_MCP_PAID, result.ruleset)).toBe(false) + }) + }) + + test("M4 系统 managed 文件损坏 ⇒ 整层 unreadable ⇒ resolver disabled(不静默丢 org deny)", async () => { + const systemDir = tmpdir("alpha-managed-system-") + fs.writeFileSync(path.join(systemDir, "opencode.json"), "{ broken") + const envDir = tmpdir("alpha-managed-env-") + writeManaged(envDir, { "*": "allow" }) + await withEnv(envDir, async () => { + const result = await readManagedPolicy({ systemDir, plist: null }) + expect(result.status).toBe("unreadable") + const effective = AlphaToolPolicy.resolveToolPolicy({ subject: mcpSubject(), caps: { managed: result }, user: NO_USER }) + expect(effective.state).toBe("disabled") + expect(effective.reason.kind).toBe("cap-managed-unreadable") + }) + }) + + test("M5 MDM plist 的 deny 同样是 cap(最高优先来源)", async () => { + const systemDir = tmpdir("alpha-managed-system-") + writeManaged(systemDir, {}) + const result = await readManagedPolicy({ + systemDir, + plist: { source: "mobileconfig:test", text: JSON.stringify({ permission: { [ID_MCP_PAID]: "deny" } }) }, + }) + expect(result.status).toBe("ok") + if (result.status !== "ok") return + expect(managedCapDenies(ID_MCP_PAID, result.ruleset)).toBe(true) + }) + + test("M6 生产系统目录是平台常量(独立字面量锚点),与 env 无关", () => { + expect(systemManagedPolicyDir("darwin")).toBe("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/Library/Application Support/opencode") + expect(systemManagedPolicyDir("linux")).toBe("/etc/opencode") + }) +}) + +// ═══ V. Effect service(#1129/#1130 消费的 API)════════════════════════════════ +const SERVICE_BASE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-policy-service-")) +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const serviceEnv = AppNodeBuilder.build( + LayerNode.group([AlphaToolPolicy.node, CrossSpawnSpawner.node, InstanceStore.node]), + [ + [InstanceStore.bootstrapNode, noopBootstrap], + [ + AlphaToolPolicy.node, + AlphaToolPolicy.layer({ + baseDir: SERVICE_BASE_DIR, + account: Effect.succeed("acct-service"), + managed: () => Promise.resolve({ status: "ok", ruleset: [], sources: [] }), + }), + ], + ], +) +const it = testEffect(serviceEnv) + +it.instance( + "V1 service:分区来自账户与当前工作区;setRecord → resolve 生效;removeRecord 回默认", + () => + Effect.gen(function* () { + const policy = yield* AlphaToolPolicy.Service + const { partition } = yield* policy.inspect() + expect(partition.account).toBe("acct-service") + expect(partition.workspace.length).toBeGreaterThan(0) + + // 默认:第三方 MCP ⇒ ask。 + expect((yield* policy.resolve(mcpSubject())).state).toBe("ask") + + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "disabled" }) + const disabled = yield* policy.resolve(mcpSubject()) + expect(disabled).toEqual({ state: "disabled", action: "deny", reason: { kind: "user", level: "tool" } }) + + yield* policy.removeRecord({ level: "tool", canonical: ID_MCP_PAID }) + expect((yield* policy.resolve(mcpSubject())).state).toBe("ask") + }), + { git: true }, +) + +it.instance( + "V2 service:quarantine 期间拒绝写入(先 reset 才能重写),resolve 全程 disabled", + () => + Effect.gen(function* () { + const policy = yield* AlphaToolPolicy.Service + const { partition } = yield* policy.inspect() + const file = policyFilePath(SERVICE_BASE_DIR, partition) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, "{ broken") + + expect((yield* policy.resolve(mcpSubject())).reason.kind).toBe("quarantine") + + const write = yield* policy + .setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "ask" }) + .pipe(Effect.exit) + expect(write._tag).toBe("Failure") + + const { backup } = yield* policy.reset() + expect(backup).toBeDefined() + expect((yield* policy.resolve(mcpSubject())).state).toBe("ask") + yield* policy.setRecord({ selector: { level: "tool", canonical: ID_MCP_PAID }, state: "ask" }) + expect((yield* policy.resolve(mcpSubject())).reason).toEqual({ kind: "user", level: "tool" }) + }), + { git: true }, +) + +// ═══ 锚点核对(方向与生产相反,只证字面量没抄错)═══════════════════════════════ +test("字面量锚点与生产编码一致", () => { + expect(selectorKey({ level: "tool", canonical: ID_MCP_PAID })).toBe(JSON.stringify(["tool", ID_MCP_PAID])) + expect(AlphaToolPolicy.mcpBindingDigest("policy", { type: "remote", url: "https://x.test/mcp" })).toMatch( + /^sha256:[0-9a-f]{64}$/, + ) + // 去秘密:headers/oauth 变了 digest 不变;url 变了 digest 变(rebind)。 + const base = AlphaToolPolicy.mcpBindingDigest("policy", { type: "remote", url: "https://x.test/mcp" }) + const withSecret = AlphaToolPolicy.mcpBindingDigest("policy", { + type: "remote", + url: "https://x.test/mcp", + headers: { authorization: "Bearer s3cret" }, + }) + const rebound = AlphaToolPolicy.mcpBindingDigest("policy", { type: "remote", url: "https://evil.test/mcp" }) + expect(withSecret).toBe(base) + expect(rebound).not.toBe(base) +}) diff --git a/packages/schema/src/alpha-tool-policy.ts b/packages/schema/src/alpha-tool-policy.ts new file mode 100644 index 000000000000..71a914b571b1 --- /dev/null +++ b/packages/schema/src/alpha-tool-policy.ts @@ -0,0 +1,215 @@ +// alpha 自有文件(basename `alpha-*` 即 north-star 谓词因子②;ADR-043)。 +// +// REQ-131 / #1128 —— 分层工具策略的共享合同(#724 CLOSE_DECIDE §2/§3/§5)。 +// +// 这里只放**纯数据与纯函数**:三态、四类、结构化 selector、matcher/compiler、 +// versioned 持久化文档形状。凡是要碰 fs / crypto / 进程环境的东西都住在 +// `packages/opencode/src/permission/alpha-tool-policy*.ts` —— 本文件要能被 +// renderer(Settings,#1130)原样 import。 +// +// 两条基线纪律,写死在类型里: +// · §3:「Settings 和调用方不得手拼 wildcard」—— selector 是结构化对象,匹配是 +// 结构相等,不存在任何字符串通配路径;`name="*"` 送进 canonical 得到的 `%2A` +// 在这里根本不是一个层级。 +// · §3:「同一 selector 只能有一条记录,不使用对象插入顺序决定安全结果」—— +// `selectorKey` 是唯一性判据,重复即坏文档(quarantine),不是 findLast。 +import { Schema, Types } from "effect" +import { + canonicalToolIdentity, + parseToolIdentity, + ToolIdentitySource, + type ToolAuthority, + type ToolIdentity, +} from "./tool-identity" + +/** UI 三态(§2)。编译到现有 Permission 的 allow/ask/deny,不另造第四种状态。 */ +export const ToolPolicyState = Schema.Literals(["enabled", "ask", "disabled"]).annotate({ + identifier: "ToolPolicyState", +}) +export type ToolPolicyState = typeof ToolPolicyState.Type + +/** 产品四类(§2 表)。用户可调用的 host / builtin-v2 归「本地工具」,不能当作不存在。 */ +export const ToolClass = Schema.Literals(["builtin", "alpha-cloud", "third-party-mcp", "plugin"]).annotate({ + identifier: "ToolClass", +}) +export type ToolClass = typeof ToolClass.Type + +const isCanonicalToolIdentity = (value: string) => { + try { + parseToolIdentity(value) + return true + } catch { + return false + } +} + +/** + * 结构化 selector(§3):class > service > tool 三层,匹配 live `ToolIdentity`。 + * tool 层的 `canonical` 必须是规范形(`parseToolIdentity` 可逆)—— 非规范形在 + * decode 时就拒绝,不会静默变成一条永不命中的死记录。 + */ +export const ToolPolicySelector = Schema.Union([ + Schema.Struct({ level: Schema.Literal("class"), class: ToolClass }), + Schema.Struct({ level: Schema.Literal("service"), source: ToolIdentitySource, origin: Schema.String }), + Schema.Struct({ + level: Schema.Literal("tool"), + canonical: Schema.String.check( + Schema.makeFilter((value) => + isCanonicalToolIdentity(value) ? undefined : "must be a normalized canonical tool identity", + ), + ), + }), +]).annotate({ discriminator: "level", identifier: "ToolPolicySelector" }) +export type ToolPolicySelector = Types.DeepMutable> + +export const BindingDigest = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/)).annotate({ + identifier: "ToolPolicyBindingDigest", +}) + +/** + * 一条用户策略记录(§3/§5)。 + * · service/tool 层的 **enabled** 必须携带 bindingDigest —— 「放宽」绑定在一个具体的 + * 宿主派生 binding 上,rebind 后自动失效回 ask(binding guard 在 resolver 里执行)。 + * · class 层是 broad intent,覆盖该作用域未来成员,不绑定 binding。 + * · disabled / ask 不要求 digest:收紧不需要证据,也不因 rebind 而失效(fail-closed)。 + */ +export const ToolPolicyRecord = Schema.Struct({ + selector: ToolPolicySelector, + state: ToolPolicyState, + bindingDigest: Schema.optional(BindingDigest), +}).check( + Schema.makeFilter<{ selector: { level: string }; state: string; bindingDigest?: string }>((record) => { + if (record.state !== "enabled") return undefined + if (record.selector.level === "class") + return record.bindingDigest === undefined ? undefined : "class-level records must not carry bindingDigest" + return record.bindingDigest !== undefined + ? undefined + : "service/tool enabled records must carry bindingDigest (binding guard, #724 §5)" + }), +) +export type ToolPolicyRecord = Types.DeepMutable> + +/** 持久化分区(§5):账户 subject(或 "anonymous")× 工作区/项目身份。 */ +export const ToolPolicyPartition = Schema.Struct({ + account: Schema.String.check(Schema.isPattern(/[\s\S]+/)), + workspace: Schema.String.check(Schema.isPattern(/[\s\S]+/)), +}).annotate({ identifier: "ToolPolicyPartition" }) +export type ToolPolicyPartition = Types.DeepMutable> + +/** + * versioned 用户策略文档(§5)。未知版本 / 部分非法 / 分区不符 = 整份 quarantine, + * 由 store 执行;本 schema 只负责「version 1 长什么样」。 + */ +export const ToolPolicyDocumentV1 = Schema.Struct({ + version: Schema.Literal(1), + partition: ToolPolicyPartition, + records: Schema.Array(ToolPolicyRecord), +}).annotate({ identifier: "ToolPolicyDocumentV1" }) +export type ToolPolicyDocumentV1 = Types.DeepMutable> + +const decodeToolPolicyDocument = Schema.decodeUnknownSync(ToolPolicyDocumentV1) + +export function parseToolPolicyDocument(value: unknown): ToolPolicyDocumentV1 { + return decodeToolPolicyDocument(value) as ToolPolicyDocumentV1 +} + +/** resolver 的判定主体:live identity + 可信 authority(+ 调用方派生的当前 binding digest)。 */ +export interface ToolPolicySubject { + readonly identity: ToolIdentity + readonly authority: ToolAuthority + /** + * 当前 binding 的宿主派生 digest(§5):第三方 MCP = 去秘密后的 server definition, + * plugin = 安装 receipt/manifest/loader generation。Alpha Cloud 不用传 —— + * verified `authority.evidenceDigest` 就是它的 binding。 + */ + readonly bindingDigest?: string +} + +/** + * 分类(§2 表):唯一可信输入是 identity.source 与 verified authority。 + * identity 非法(canonical 铸不出来)⇒ undefined ⇒ 调用方必须按 disabled 处置。 + * 标题 / annotation / technicalId / URL 相似性一概不是输入(§7)。 + */ +export function classifyTool(subject: { + identity: ToolIdentity + authority: ToolAuthority +}): ToolClass | undefined { + try { + canonicalToolIdentity(subject.identity) + } catch { + return undefined + } + switch (subject.identity.source) { + case "builtin": + case "builtin-v2": + case "host": + return "builtin" + case "mcp": + return subject.authority.kind === "alpha-cloud" ? "alpha-cloud" : "third-party-mcp" + case "plugin": + return "plugin" + default: + return undefined + } +} + +/** 四类默认(§2 表):本地 enabled,其余一律 ask。 */ +export function classDefaultState(cls: ToolClass): ToolPolicyState { + return cls === "builtin" ? "enabled" : "ask" +} + +/** + * selector 唯一键(§3「同一 selector 只能有一条记录」)。 + * 用 JSON 数组做无歧义分隔 —— origin 里的 `:` / `*` 都是字面字符,不是语法。 + */ +export function selectorKey(selector: ToolPolicySelector): string { + switch (selector.level) { + case "class": + return JSON.stringify(["class", selector.class]) + case "service": + return JSON.stringify(["service", selector.source, selector.origin]) + case "tool": + return JSON.stringify(["tool", selector.canonical]) + } +} + +/** tool > service > class(§3)。数值只用于比大小,不持久化。 */ +export function selectorSpecificity(selector: ToolPolicySelector): number { + switch (selector.level) { + case "tool": + return 3 + case "service": + return 2 + case "class": + return 1 + } +} + +/** + * 共享 matcher/compiler(§3):selector 对 live identity 的匹配是**结构相等**, + * 没有任何字符串通配语义。identity 非法 ⇒ 一律不匹配(resolver 早已把它判成 disabled)。 + */ +export function selectorMatches(selector: ToolPolicySelector, subject: ToolPolicySubject): boolean { + const cls = classifyTool(subject) + if (cls === undefined) return false + switch (selector.level) { + case "class": + return selector.class === cls + case "service": + return selector.source === subject.identity.source && selector.origin === subject.identity.origin + case "tool": + return selector.canonical === canonicalToolIdentity(subject.identity) + } +} + +/** 三态 → 现有 Permission 动作(§2:不另造第四种状态,也不另造第二个审批引擎)。 */ +export function toPermissionAction(state: ToolPolicyState): "allow" | "ask" | "deny" { + switch (state) { + case "enabled": + return "allow" + case "ask": + return "ask" + case "disabled": + return "deny" + } +} diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index 9e9ce94fee78..6d7d2f307eef 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -146,6 +146,8 @@ 10 packages/opencode test/tool/alpha-tool-identity.test.ts - ADR-041/#878:身份 canonical 转义/解析、非法与非规范输入拒绝、alias/case/reverse collision、verified binding 才能声明 Alpha Cloud、伪 URL/标题/图标/annotation 不得冒充、identity deny 与不完整 authority fail-closed;另有 code-mode 禁 alias-prefix 反解、首次写入禁 live catalog 两条源码棘轮、StructuredOutput 完整 host identity 与 workflow canonical preapproval 行为闸。下界=实际条数 10,不留余量 45 packages/opencode test/tool/alpha-websearch-failure.test.ts packages/ui-mac/src/main/stream-read-hygiene.test.ts #647 readBoundedBody 读全 + MAX_BODY_BYTES 硬限 + #489/#223 失败诚实 5 packages/opencode test/permission/alpha-ask-deadline.test.ts - ADR-038/#668:v1 审批请求无人应答时在期限内**具名失败**(Exit 必须 failure = 绝不到点自动放行)+ 广播 reject 回执 + pending 清空 + 默认期限不被调成无期限 +6 packages/opencode test/permission/alpha-session-grants.test.ts - #1128/#724 §4.4-4.5(#1122 B9/B13):真实 V1 Permission 引擎上,`always` 产生的 session grant ①压不过 ruleset deny(ask 必须以 DeniedError 失败)②不跨会话(B 会话同 subject 必须重新进待批队列)③同会话同 subject discharge ask(第二次直接返回、pending 全程为空)④`once` 不落账 ⑤`clearGrants` 清账后重新问、只清指定会话;另有 SUBJECT 字面量与生产 canonical 编码的独立锚点核对。变异实测:grant 拼回 evaluate 与 deny 竞争 ⇒ ①红;grant 匹配去掉 sessionID ⇒ ②⑤红。条数=实测,不留余量 +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 循环早退时也红。条数=实测,不留余量 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,不留余量:留余量则删一格仍能过