From 7028c244fdb1f6c2c03726f8c5a170b268f7526f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 15:51:38 +0800 Subject: [PATCH] =?UTF-8?q?dsh=20=E6=A1=A5=E5=90=8C=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=EF=BC=9A409=20=E5=B8=A6=20full=20=E6=A1=A3?= =?UTF-8?q?=E6=97=B6=E4=B9=9F=E5=BD=93=E5=9C=BA=E6=8B=92=E7=BB=9D=EF=BC=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=94=E6=8A=8A=E4=BC=9A=E8=AF=9D=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 报的是它自己的桥,但**同一处缺陷 dsh 桥也有**(`src/index.ts` 的 409 分支无条件 `return 'rejected'`),而且后果多一层 —— dsh 的档位是通过 `applyPermissionMode()` 写进会话的(沙箱 + 审批策略)。补投漏传档位时 `applyPermissionMode(session, '')` 把会话**降级**成 workspace:`danger-full-access → workspace-write`、 `never → ask`,于是每个受守卫的工具调用都去问一次,再被 409 拒绝 —— 一条 full 档会话只要有一封补投邮件,这一轮的工具调用全被自己人拦死, **顺带把自己的权限也降了**。 修法同 pi 桥:409 分支先认回包里的 `permission_mode`,是 full 就 `return 'allowed-once'`(DSH 的 ApprovalOutcome 只认 allowed-once/rejected/cancelled/unavailable); plan 档与"链上没有人类"照旧 `return 'rejected'`。放行分支排在普通 409 之前,否则不可达。 判据 `test/permission-409-full.test.mjs`(含自检:拿掉放行分支必须红)。 自检那步发现我第一版判据又踩了同一个坑:「普通 409 分支里不许出现 allowed-once」 读的是**含注释**的正文,而那段的注释正好写着 "ApprovalOutcome 只认 allowed-once / rejected / …" → 误报。改成读剥注释的源码(规范里那条: 判"代码里有什么"读剥离版,判"理由写清了没"读原文)。 zcode / opencode 不转发权限询问(没有 409 分支),无需改。 验证:dsh 套件 383 通过(+2);变异(拿掉放行分支)→ 自检红。 --- plugins/dsh-mail-bridge/src/index.ts | 25 +++++++ .../test/permission-409-full.test.mjs | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 plugins/dsh-mail-bridge/test/permission-409-full.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 3aa25d2..68ddc10 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -1814,6 +1814,31 @@ export function apply(ctx: any, config: PluginConfig): void { // // 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到 // 拒绝后会自己换方式,而挂死时它连重试的机会都没有。 + /* + * 409 有**两种**含义,正确反应相反(2026-09-14 修,与 pi 桥同一处缺陷): + * + * - 「这条链上没有人类」→ 当场拒绝(上面的理由); + * - 「**本档根本不该问**」→ 服务端会在回包里带 `permission_mode`。 + * 若它说这条会话是 full 档,那这次询问本就不该发生(full 档工具调用无需审批), + * 正确反应是**放行**,不是拒绝。 + * + * 什么时候会走到第二种:补投路径漏传档位(`lib/catchup.js`,同日已修), + * 于是 `applyPermissionMode(session, '')` 把会话**降级**成 workspace: + * 沙箱从 danger-full-access 变 workspace-write、审批从 never 变 ask, + * 每个受守卫的工具调用都去问一次,服务端按真实档位回 409 —— + * 旧代码把它当「无人可问」拒绝,一条 full 档会话只要有一封补投邮件, + * 这一轮的工具调用全被自己人拦死。 + * + * **只认服务端明说的 full**:plan 档(该档语义是"不动手",拒绝是对的) + * 与"链上没有人类"照旧拒绝 —— 猜宽了就是提权。 + */ + if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full') { + console.error( + `[dsh-mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批` + ); + return 'allowed-once'; + } + if (e?.status === 409) { const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion] .filter(Boolean).join(' '); diff --git a/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs b/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs new file mode 100644 index 0000000..4e1e522 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs @@ -0,0 +1,73 @@ +/** + * 接线断言:服务端 409 里说「本会话是 full 档」时,dsh 桥必须**放行**而不是拒绝。 + * + * # 为什么需要这条(与 pi 桥同一处缺陷,同日一起修) + * + * 409 原本只有一个含义:「这条任务链上没有人类」→ 当场拒绝(`return 'rejected'`)。 + * 但 `handler/permission.go` 在**档位不该问人**时也回 409,并在回包里带 + * `permission_mode`。两种 409 的正确反应相反:无人可问 → 拒绝;full 档 → **放行**。 + * + * dsh 桥还有一层放大:档位是从邮件事件 `applyPermissionMode()` 应用的, + * 补投路径漏传档位时(`lib/catchup.js`,同日修)会话被**降级**成 workspace —— + * 沙箱 danger-full-access → workspace-write、审批 never → ask, + * 于是每个受守卫的工具调用都去问一次,再被 409 拒绝。一条 full 档会话只要有一封 + * 补投邮件,这一轮的工具调用就全被自己人拦死。 + * + * # 为什么读源码而不是跑起来 + * + * 与 `permission-forward-wiring.test.mjs` 同一取舍:入口不是可导入的模块 + * (插件工厂 + Cordis 依赖)。这里要钉的只有一件事:那个放行分支还在、且认服务端给的档位。 + * + * # 判据自检 + * + * 拿掉放行分支的源码喂给它,必须红 —— 否则这条断言永远为真,比没有更糟。 + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); + +/** 取 409 相关分支的正文(按花括号配对,不看窗口) */ +function blockAt(src, needle) { + const at = src.indexOf(needle); + if (at < 0) return ''; + const open = src.indexOf('{', at); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); } + } + return ''; +} + +test('★ 409 + full 档 → 放行;plan / 无人可问 → 仍拒绝', () => { + const full = blockAt(SRC, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')"); + assert.ok(full.length > 40, '要能找到「409 + full」分支(找不到说明结构变了,判据要跟着改)'); + assert.match(full, /return 'allowed-once'/, 'full 档要放行(DSH 的 ApprovalOutcome 只认 allowed-once/rejected/…)'); + + const plain = blockAt(SRC, 'if (e?.status === 409) {'); + assert.ok(plain.length > 100, '要能找到普通 409 分支'); + assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝'); + /* + * ⚠️ 「代码里有没有放行」必须读**剥掉注释**的正文:普通 409 分支的注释里正好写着 + * "ApprovalOutcome 只认 allowed-once / rejected / …"(解释为什么写 'rejected' + * 而不是 'denied')—— 读原文会把那句话当成"这里会放行"而误报。 + * 这是本仓规范里那条:判"代码里有什么"读剥离版,判"理由写清了没"读原文。 + */ + const plainCode = plain.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + assert.ok(!/allowed-once/.test(plainCode), + `普通 409 分支不许放行(那就等于"没人可问也放行"):${(plainCode.match(/allowed-once/) || [''])[0]}`); + // 顺序:full 分支必须在普通 409 之前,否则永远不可达 + assert.ok(SRC.indexOf("permission_mode || '') === 'full'") < SRC.indexOf('if (e?.status === 409) {'), + '放行分支要排在普通 409 之前(否则被前面的分支吃掉,永远不可达)'); +}); + +test('★ 判据自检:拿掉放行分支,上面那条必须红', () => { + const crippled = SRC.replace(/if \(e\?\.status === 409 && String\(e\?\.body\?\.permission_mode \|\| ''\) === 'full'\)[\s\S]*?\n \}\n/, ''); + assert.equal(blockAt(crippled, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')").length, 0, + '自检:判据要能检出"放行分支被拿掉"这件事'); +});