From a60ab66a40659f9c29129e2d75133f8ba9c808bb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 23:59:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(plugins):=20=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E3=80=8C=E6=91=86=E4=BA=86=E4=B8=80=E5=A5=97=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=8D=B4=E6=B2=A1=E4=BA=BA=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E3=80=81=E4=B8=94=E5=BD=A2=E7=8A=B6=E6=B2=A1=E4=BA=BA=E8=83=BD?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E3=80=8D=E7=9A=84=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 问题 `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) --- docs/PLUGIN-CONTRACT.md | 73 +++++++++++++++++++ .../dsh-mail-bridge/lib/permission-mode.d.ts | 7 -- .../dsh-mail-bridge/lib/permission-mode.js | 67 ----------------- .../test/permission-mode.test.mjs | 70 +----------------- plugins/opencode-mail-bridge/index.js | 2 +- .../lib/permission-mode.js | 67 ----------------- .../test/permission-mode.test.mjs | 70 +----------------- plugins/pi-mail-bridge/lib/permission-mode.js | 67 ----------------- .../test/permission-mode.test.mjs | 70 +----------------- 9 files changed, 77 insertions(+), 416 deletions(-) diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index 1869b0e..a4487c5 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -1741,6 +1741,79 @@ permission_requests.result 全是「同意」—— 一次「一直同意」都 --- +### 9.18 摆一套权限规则,却从不调用,且形状根本没人能消费 + +比 `9.17`(给了按钮不实现)更深一层的错:**代码看起来像强制力的实现**。 + +`lib/permission-mode.js` 里曾有 `opencodePermissions(mode)`,实现完整、注释详实 +(含 6 条实测结论)、还有 9 条测试把它的行为钉住。opencode 的 `index.js` +第 45 行确实 import 了它 —— 然后**全文件再也没有第二次出现**。 + +静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为 +「opencode 的 plan 档由这套规则拦着」。实际不是:opencode 是 `advisory`。 + +#### 它不只是没接上,而是**接不上** + +查 1.18.29 的 SDK 类型定义,三条都排除: + +```ts +// ① 会话级参数里没有 permission,也没有 agent(只有这两个字段) +export type SessionCreateData = { + body?: { parentID?: string; title?: string }; + query?: { directory?: string }; +}; + +// ② permission 只存在于 Config / AgentConfig,而且是 map 形状 +export type Config = { permission?: { + edit?: "ask"|"allow"|"deny"; bash?: …; + webfetch?: …; doom_loop?: …; external_directory?: …; +}; }; + +// ③ 权限事件也没有 action 字段 +export type Permission = { + id, type, pattern?, sessionID, messageID, callID?, title, metadata, time +}; +``` + +而那个函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**, +并且 deny 了 `task`(本版本 `permission` 的合法键里**没有 task**)。 + +所以这不是「加一行调用就能生效」,而是**输出没有任何消费者**。 + +#### 为什么 per-session 强制在 opencode 上做不到 + +`Config` / `AgentConfig` 是**配置文件级**(全局或项目级)。邮件桥若靠改配置来 +给某条会话加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— +一条 plan 档的邮件把人身兼的其他工作一起禁掉,不可接受。 + +opencode 目前的拦截路径只能是「它自己先问 → 桥转发 → 服务端按档位 409 → +桥当场 block」,**前提是它的配置恰好是 `ask`**;若配置直接 `allow`, +桥连 `permission.updated` 事件都看不到。这就是它只能是 `advisory` 的原因。 + +#### 处理:删掉函数,知识搬进本节 + +死代码留着就是陷阱。函数与 9 条测试已删除,6 条实测结论保留在这里: + +1. **`findLast` 胜出** —— 规则数组里 deny 必须排在 allow **之前**, + 反了的话最后匹配到 deny,连允许的路径也被拒。 +2. **pattern 匹配 worktree 相对路径** —— 写 `/tmp/**` 这种绝对 pattern 永远匹配不上 + (`/tmp/x` 相对 `/home/program/agentmail` 是 `../../../tmp/x`)。 +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 则工具保留、越界调用才报错。 +5. **`task`(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write, + 主动 `task` 委派给一个带 write 的子代理去写成了。 +6. **`bash` 能绕过 `edit` 的路径限制** —— 模型用 shell 重定向写成了本该被 deny 的文件。 + +另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档 +**撞名但语义不同**(那是它自己的计划模式开关),不要碰它们。 + +> 自查:一个 `export function` 写完之后,除了它的测试,还有谁调用它? +> 只有 import 行、没有调用点时,先查**它的输出形状在目标平台里到底有没有消费者** —— +> 「形状没人认」比「忘了接线」更常见,且更隐蔽。 + +--- + ## 附:文档关系 | 文档 | 内容 | diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts index 2c587e3..1705575 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts +++ b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts @@ -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'; diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.js b/plugins/dsh-mail-bridge/lib/permission-mode.js index cb5656c..cbb6293 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.js +++ b/plugins/dsh-mail-bridge/lib/permission-mode.js @@ -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 的沙箱模式。 * diff --git a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs index f4826cb..d3f52aa 100644 --- a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs @@ -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、6:edit 覆盖 write/edit/patch;task 会绕过;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} 不该被禁`); - } -}); - -// 实测结论 1:findLast 胜出 → 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,连允许的路径也被拒'); -}); - -// 实测结论 2:pattern 匹配 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 三档与原生沙箱一一对应', () => { diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 0e7574e..00b2b96 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -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"; diff --git a/plugins/opencode-mail-bridge/lib/permission-mode.js b/plugins/opencode-mail-bridge/lib/permission-mode.js index cb5656c..cbb6293 100644 --- a/plugins/opencode-mail-bridge/lib/permission-mode.js +++ b/plugins/opencode-mail-bridge/lib/permission-mode.js @@ -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 的沙箱模式。 * diff --git a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs index f4826cb..d3f52aa 100644 --- a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs @@ -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、6:edit 覆盖 write/edit/patch;task 会绕过;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} 不该被禁`); - } -}); - -// 实测结论 1:findLast 胜出 → 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,连允许的路径也被拒'); -}); - -// 实测结论 2:pattern 匹配 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 三档与原生沙箱一一对应', () => { diff --git a/plugins/pi-mail-bridge/lib/permission-mode.js b/plugins/pi-mail-bridge/lib/permission-mode.js index cb5656c..cbb6293 100644 --- a/plugins/pi-mail-bridge/lib/permission-mode.js +++ b/plugins/pi-mail-bridge/lib/permission-mode.js @@ -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 的沙箱模式。 * diff --git a/plugins/pi-mail-bridge/test/permission-mode.test.mjs b/plugins/pi-mail-bridge/test/permission-mode.test.mjs index f4826cb..d3f52aa 100644 --- a/plugins/pi-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/pi-mail-bridge/test/permission-mode.test.mjs @@ -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、6:edit 覆盖 write/edit/patch;task 会绕过;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} 不该被禁`); - } -}); - -// 实测结论 1:findLast 胜出 → 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,连允许的路径也被拒'); -}); - -// 实测结论 2:pattern 匹配 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 三档与原生沙箱一一对应', () => {