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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
90 changes: 90 additions & 0 deletions docs/contracts/tool-policy.md
Original file line number Diff line number Diff line change
@@ -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:<server>:*` 在 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-<ts>` 备份,回到批准默认)。
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)。
163 changes: 163 additions & 0 deletions packages/opencode/src/permission/alpha-managed-policy.ts
Original file line number Diff line number Diff line change
@@ -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 "/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<string, unknown>)["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<ManagedPolicyResult> {
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"
}
123 changes: 123 additions & 0 deletions packages/opencode/src/permission/alpha-tool-policy-store.ts
Original file line number Diff line number Diff line change
@@ -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-<ts>` 备份,回到默认),给 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<string>()
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 }
}
Loading
Loading