refactor(plugins): 删掉「摆了一套权限规则却没人调用、且形状没人能消费」的死代码

# 问题

`lib/permission-mode.js` 里的 `opencodePermissions(mode)` 实现完整、注释详实
(含 6 条实测结论)、还有 9 条测试把行为钉住;opencode 的 `index.js` 第 45 行
确实 import 了它 —— 然后**全文件再没有第二次出现**。

静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为
「opencode 的 plan 档由这套规则拦着」。实际 opencode 是 advisory。

比 9.17(给了按钮不实现)更深一层:那只是没实现,这个是**看起来像实现**。

# 它不只是没接上,而是接不上

查 1.18.29 的 SDK 类型定义,三条都排除:

  SessionCreateData.body 只有 { parentID, title };query 只有 { directory }
      → 会话级根本没有 permission,也没有 agent
  permission 只存在于 Config / AgentConfig,且是 map 形状
      → { edit, bash, webfetch, doom_loop, external_directory } → ask|allow|deny
  Permission 事件(permission.updated 的 properties)没有 action 字段
      → { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }

而函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**,
并且 deny 了 `task`(本版本 permission 的合法键里没有 task)。

所以不是「加一行调用就生效」,而是**输出没有任何消费者**。

# 为什么 opencode 的 per-session 强制做不到(如实说明)

Config / AgentConfig 是配置文件级(全局或项目级)。邮件桥若靠改配置给某条会话
加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件
把人身兼的其他工作一起禁掉,不可接受。

opencode 目前唯一的拦截路径是「它自己先问 → 桥转发 → 服务端按档位 409 →
桥当场 block」,**前提是它的配置恰好是 ask**;若配置直接 allow,桥连
permission.updated 都看不到。这就是它只能是 advisory 的原因,不是缺工作量。

# 改动

- 删除 `opencodePermissions` 及其 doc 注释、`OpencodePermissionRule` 接口声明
  (三份共用库同时改,改后 md5 仍逐字节相同)
- 删除 opencode/index.js 里那行未使用的 import
- 删除 9 条针对该函数的断言(三桥各 9 条)
- **6 条实测结论没有丢** —— 搬进 `docs/PLUGIN-CONTRACT.md` 新增的 §9.18,
  连同上面那三条类型证据与「为什么 per-session 做不到」

删掉而非保留,是因为留下的就是陷阱:函数存在、注释写着「实测过」、
测试还全绿,唯一缺的是调用点 —— 下一个人会以为档位在这里被强制。

# 验证

- `deploy/check-shared-libs.sh` → 共用模块三方同源
- 三桥 `npm test` 全绿:pi 400 / dsh 360 / opencode 311,0 失败
  (删除前 pi 409 / opencode 320,各 −9 即被删断言;dsh 另有并发提交新增测试,
  净 −9 后为 360)
- `node --check` 四个改动文件全过;ESM 动态 import 该 lib 成功,
  导出里已无 `opencodePermissions`,index.js 只引用仍存在的符号
- 本改动不影响运行时行为(删的是一个从未执行的函数与一个未使用的 import)
This commit is contained in:
2026-09-11 23:59:24 +08:00
parent 4e32dd3145
commit a60ab66a40
9 changed files with 77 additions and 416 deletions

View File

@ -13,13 +13,6 @@ export function normalizeEnforcement(e: unknown): string;
export function modeAtMost(a: string, b: string): string;
export function modeNeedsHuman(mode: string): boolean;
export interface OpencodePermissionRule {
permission: string;
action: string;
pattern: string;
}
export function opencodePermissions(mode: string): OpencodePermissionRule[];
export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access';
export function dshApprovalPolicy(mode: string): 'ask' | 'never';

View File

@ -108,73 +108,6 @@ export function modeNeedsHuman(mode) {
return normalizeMode(mode) === MODE_WORKSPACE;
}
/**
* opencode 的 permission 规则数组。
*
* ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西)
*
* 1. **规则是 findLast 胜出**(二进制里
* `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`
* → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。
* 2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree,file)]`
* → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对
* `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。
* 3. **write / edit / patch 共用 `edit` 一个权限名**
* `if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。
* 4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool
* available in this session」部分 deny 则工具保留、越界调用才报错。
* plan 档用前者更好:模型不会浪费轮次去试。
* 5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write
* 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须
* `task deny *`,否则档位形同虚设。
* 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被
* deny 的文件。所以 workspace 档必须同时管 bash只管 edit 没用。
*
* 另注opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档
* **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。
*
* @param {string} mode
* @returns {{permission: string, action: string, pattern: string}[]}
*/
export function opencodePermissions(mode) {
const m = normalizeMode(mode);
if (m === MODE_FULL) {
// 全权:不下发任何规则,用平台自己的默认配置。
// 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。
return [];
}
if (m === MODE_PLAN) {
// 只读。四项都要 deny
// - edit 覆盖 write/edit/patch
// - bash 否则 shell 重定向就能写文件(实测过)
// - task 否则子代理能绕过(实测过)
// - webfetch/websearch 不禁:查资料是 plan 档的本职
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'bash', action: 'deny', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
// workspace目录内可写越界问人。
//
// deny 在前、allow 在后findLast 胜出。pattern `**` 是 worktree
// 相对路径,等价于「这个工作目录内的任何文件」。
//
// bash 一律 ask 而不是 allow命令要碰哪些文件解析不出来
// 这就是「向更严取整」——比声明的严,不比它松。
//
// task 仍然 deny子代理带着自己的权限跑父会话的边界对它无效。
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'edit', action: 'allow', pattern: '**' },
{ permission: 'bash', action: 'ask', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
/**
* DSH 的沙箱模式。
*

View File

@ -12,7 +12,7 @@ import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
} from '../lib/permission-mode.js';
@ -80,74 +80,6 @@ test('脏档位按默认档处理,即需要人(宁可多问一次)', () =>
assert.equal(modeNeedsHuman(''), true);
});
// ─── opencode ───
test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => {
assert.deepEqual(opencodePermissions(MODE_FULL), []);
});
// 实测结论 3、5、6edit 覆盖 write/edit/patchtask 会绕过bash 能重定向写文件
test('plan 档同时 deny edit / bash / task', () => {
const rules = opencodePermissions(MODE_PLAN);
const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission));
assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch必须 deny');
assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny');
assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny');
});
test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => {
const rules = opencodePermissions(MODE_PLAN);
for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) {
assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`);
}
});
// 实测结论 1findLast 胜出 → deny 必须在 allow 之前
test('workspace 档的 edit 规则 deny 在前 allow 在后findLast 胜出)', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny');
const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在');
assert.ok(denyIdx < allowIdx,
'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny连允许的路径也被拒');
});
// 实测结论 2pattern 匹配 worktree 相对路径,绝对路径永远匹配不上
test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(allow, '要有 allow 规则');
assert.equal(allow.pattern.startsWith('/'), false,
'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)');
});
// 向更严取整:命令要碰哪些文件解析不出来
test('workspace 档的 bash 是 ask 而不是 allow向更严取整', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const bash = rules.find(r => r.permission === 'bash');
assert.equal(bash.action, 'ask',
'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松');
});
test('workspace 档仍然 deny task子代理带自己的权限跑', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const task = rules.find(r => r.permission === 'task');
assert.equal(task.action, 'deny');
});
test('opencode 规则不碰 plan_enter / plan_exit撞名但语义不同', () => {
for (const m of MODES) {
for (const r of opencodePermissions(m)) {
assert.notEqual(r.permission, 'plan_enter');
assert.notEqual(r.permission, 'plan_exit');
}
}
});
test('脏档位按默认档下发(与 workspace 相同)', () => {
assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE));
});
// ─── DSH ───
test('DSH 三档与原生沙箱一一对应', () => {

View File

@ -42,7 +42,7 @@ import { createSSEClient } from "./lib/sse-client.js";
// opencode 原生支持三态权限免批由它自己记response:"always"
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
import { opencodePermissions, normalizeMode, modeBriefing } from "./lib/permission-mode.js";
import { normalizeMode, modeBriefing } from "./lib/permission-mode.js";
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";

View File

@ -108,73 +108,6 @@ export function modeNeedsHuman(mode) {
return normalizeMode(mode) === MODE_WORKSPACE;
}
/**
* opencode 的 permission 规则数组。
*
* ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西)
*
* 1. **规则是 findLast 胜出**(二进制里
* `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`
* → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。
* 2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree,file)]`
* → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对
* `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。
* 3. **write / edit / patch 共用 `edit` 一个权限名**
* `if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。
* 4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool
* available in this session」部分 deny 则工具保留、越界调用才报错。
* plan 档用前者更好:模型不会浪费轮次去试。
* 5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write
* 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须
* `task deny *`,否则档位形同虚设。
* 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被
* deny 的文件。所以 workspace 档必须同时管 bash只管 edit 没用。
*
* 另注opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档
* **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。
*
* @param {string} mode
* @returns {{permission: string, action: string, pattern: string}[]}
*/
export function opencodePermissions(mode) {
const m = normalizeMode(mode);
if (m === MODE_FULL) {
// 全权:不下发任何规则,用平台自己的默认配置。
// 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。
return [];
}
if (m === MODE_PLAN) {
// 只读。四项都要 deny
// - edit 覆盖 write/edit/patch
// - bash 否则 shell 重定向就能写文件(实测过)
// - task 否则子代理能绕过(实测过)
// - webfetch/websearch 不禁:查资料是 plan 档的本职
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'bash', action: 'deny', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
// workspace目录内可写越界问人。
//
// deny 在前、allow 在后findLast 胜出。pattern `**` 是 worktree
// 相对路径,等价于「这个工作目录内的任何文件」。
//
// bash 一律 ask 而不是 allow命令要碰哪些文件解析不出来
// 这就是「向更严取整」——比声明的严,不比它松。
//
// task 仍然 deny子代理带着自己的权限跑父会话的边界对它无效。
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'edit', action: 'allow', pattern: '**' },
{ permission: 'bash', action: 'ask', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
/**
* DSH 的沙箱模式。
*

View File

@ -12,7 +12,7 @@ import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
} from '../lib/permission-mode.js';
@ -80,74 +80,6 @@ test('脏档位按默认档处理,即需要人(宁可多问一次)', () =>
assert.equal(modeNeedsHuman(''), true);
});
// ─── opencode ───
test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => {
assert.deepEqual(opencodePermissions(MODE_FULL), []);
});
// 实测结论 3、5、6edit 覆盖 write/edit/patchtask 会绕过bash 能重定向写文件
test('plan 档同时 deny edit / bash / task', () => {
const rules = opencodePermissions(MODE_PLAN);
const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission));
assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch必须 deny');
assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny');
assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny');
});
test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => {
const rules = opencodePermissions(MODE_PLAN);
for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) {
assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`);
}
});
// 实测结论 1findLast 胜出 → deny 必须在 allow 之前
test('workspace 档的 edit 规则 deny 在前 allow 在后findLast 胜出)', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny');
const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在');
assert.ok(denyIdx < allowIdx,
'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny连允许的路径也被拒');
});
// 实测结论 2pattern 匹配 worktree 相对路径,绝对路径永远匹配不上
test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(allow, '要有 allow 规则');
assert.equal(allow.pattern.startsWith('/'), false,
'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)');
});
// 向更严取整:命令要碰哪些文件解析不出来
test('workspace 档的 bash 是 ask 而不是 allow向更严取整', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const bash = rules.find(r => r.permission === 'bash');
assert.equal(bash.action, 'ask',
'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松');
});
test('workspace 档仍然 deny task子代理带自己的权限跑', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const task = rules.find(r => r.permission === 'task');
assert.equal(task.action, 'deny');
});
test('opencode 规则不碰 plan_enter / plan_exit撞名但语义不同', () => {
for (const m of MODES) {
for (const r of opencodePermissions(m)) {
assert.notEqual(r.permission, 'plan_enter');
assert.notEqual(r.permission, 'plan_exit');
}
}
});
test('脏档位按默认档下发(与 workspace 相同)', () => {
assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE));
});
// ─── DSH ───
test('DSH 三档与原生沙箱一一对应', () => {

View File

@ -108,73 +108,6 @@ export function modeNeedsHuman(mode) {
return normalizeMode(mode) === MODE_WORKSPACE;
}
/**
* opencode 的 permission 规则数组。
*
* ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西)
*
* 1. **规则是 findLast 胜出**(二进制里
* `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`
* → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。
* 2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree,file)]`
* → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对
* `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。
* 3. **write / edit / patch 共用 `edit` 一个权限名**
* `if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。
* 4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool
* available in this session」部分 deny 则工具保留、越界调用才报错。
* plan 档用前者更好:模型不会浪费轮次去试。
* 5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write
* 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须
* `task deny *`,否则档位形同虚设。
* 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被
* deny 的文件。所以 workspace 档必须同时管 bash只管 edit 没用。
*
* 另注opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档
* **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。
*
* @param {string} mode
* @returns {{permission: string, action: string, pattern: string}[]}
*/
export function opencodePermissions(mode) {
const m = normalizeMode(mode);
if (m === MODE_FULL) {
// 全权:不下发任何规则,用平台自己的默认配置。
// 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。
return [];
}
if (m === MODE_PLAN) {
// 只读。四项都要 deny
// - edit 覆盖 write/edit/patch
// - bash 否则 shell 重定向就能写文件(实测过)
// - task 否则子代理能绕过(实测过)
// - webfetch/websearch 不禁:查资料是 plan 档的本职
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'bash', action: 'deny', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
// workspace目录内可写越界问人。
//
// deny 在前、allow 在后findLast 胜出。pattern `**` 是 worktree
// 相对路径,等价于「这个工作目录内的任何文件」。
//
// bash 一律 ask 而不是 allow命令要碰哪些文件解析不出来
// 这就是「向更严取整」——比声明的严,不比它松。
//
// task 仍然 deny子代理带着自己的权限跑父会话的边界对它无效。
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'edit', action: 'allow', pattern: '**' },
{ permission: 'bash', action: 'ask', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
/**
* DSH 的沙箱模式。
*

View File

@ -12,7 +12,7 @@ import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
} from '../lib/permission-mode.js';
@ -80,74 +80,6 @@ test('脏档位按默认档处理,即需要人(宁可多问一次)', () =>
assert.equal(modeNeedsHuman(''), true);
});
// ─── opencode ───
test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => {
assert.deepEqual(opencodePermissions(MODE_FULL), []);
});
// 实测结论 3、5、6edit 覆盖 write/edit/patchtask 会绕过bash 能重定向写文件
test('plan 档同时 deny edit / bash / task', () => {
const rules = opencodePermissions(MODE_PLAN);
const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission));
assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch必须 deny');
assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny');
assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny');
});
test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => {
const rules = opencodePermissions(MODE_PLAN);
for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) {
assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`);
}
});
// 实测结论 1findLast 胜出 → deny 必须在 allow 之前
test('workspace 档的 edit 规则 deny 在前 allow 在后findLast 胜出)', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny');
const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在');
assert.ok(denyIdx < allowIdx,
'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny连允许的路径也被拒');
});
// 实测结论 2pattern 匹配 worktree 相对路径,绝对路径永远匹配不上
test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow');
assert.ok(allow, '要有 allow 规则');
assert.equal(allow.pattern.startsWith('/'), false,
'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)');
});
// 向更严取整:命令要碰哪些文件解析不出来
test('workspace 档的 bash 是 ask 而不是 allow向更严取整', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const bash = rules.find(r => r.permission === 'bash');
assert.equal(bash.action, 'ask',
'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松');
});
test('workspace 档仍然 deny task子代理带自己的权限跑', () => {
const rules = opencodePermissions(MODE_WORKSPACE);
const task = rules.find(r => r.permission === 'task');
assert.equal(task.action, 'deny');
});
test('opencode 规则不碰 plan_enter / plan_exit撞名但语义不同', () => {
for (const m of MODES) {
for (const r of opencodePermissions(m)) {
assert.notEqual(r.permission, 'plan_enter');
assert.notEqual(r.permission, 'plan_exit');
}
}
});
test('脏档位按默认档下发(与 workspace 相同)', () => {
assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE));
});
// ─── DSH ───
test('DSH 三档与原生沙箱一一对应', () => {