From 9fa509844abb1a56dfec8145e0601a7d31783aca Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 23:50:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(pi-bridge):=20=E6=9C=89=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=E6=97=B6=20workspace=20=E6=A1=A3=E4=B8=8D=E5=86=8D=E9=80=90?= =?UTF-8?q?=E6=9D=A1=E9=97=AE=E4=BA=BA=20=E2=80=94=E2=80=94=20=E7=95=8C?= =?UTF-8?q?=E5=86=85=E4=B8=8D=E9=97=AE=E3=80=81=E7=95=8C=E5=A4=96=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沙箱上线后,"工作区档"的语义第一次可以按档位表兑现:**边界是内核在守**,再问一遍 只是让人点一次"同意",点完该失败的还是失败(人点了也挡不住内核)。所以闸门改成 **按档位 × 有没有沙箱** 决策,纯函数收在 `lib/sandbox.js`: | 档位 | 沙箱 | 决定 | |---|---|---| | full | 任意 | allow(发件人已声明全权) | | plan | 任意 | block(本档只许看;沙箱是第二层) | | workspace | **在** | **allow** ← 这一步改的(界内不问、界外 EACCES) | | workspace | 不在 | ask(回退到原来那唯一一层) | 没有沙箱时**继续问** —— 这条是"不会更松"的保证:沙箱缺失/未装/被关掉时行为与改前 逐字一致。 ## 标记不等于事实:worker 自证 `AGENTMAIL_PI_SANDBOXED=1` 只是父进程的**声明**。判断错会让闸门既不问也不拦 (最坏的一类),所以 worker 现场自证一次:往界外写一个金丝雀(`/.agentmail-sandbox-canary-`, 根目录永远不在 rw 里)—— 写得进去 ⇒ 判为"没有沙箱",**退回逐条问人**(方向取严); 被拒(EACCES/EROFS/EPERM)⇒ 在边界内。结果缓存在进程级。 ## 顺带把 plan 档变成真的只读 plan 档的 rw 清单**不含会话工作区**(只有临时目录/pi 会话登记/桥配置/`/dev/null`): "一个字都不许写"从"钩子拒绝 + 提示词"{升级为内核第二层。 ## 判据 - `sandbox-launch.test.mjs` 10 条(原 6 + 新 4):决策矩阵四档 × 有无沙箱、 自证两侧(被拒=在边界内;能写=必须判"没沙箱")、plan 档 rw 不含工作区、 "pool 设标记 + worker 自证 + 走 guardDecision"三处接线在。 - 变异:把 workspace+sandboxed 改回 'ask' ⇒ 那条断言红。 - pi 桥全套 489 项通过。 - ★ 又被自己撞一次同类坑并当场红:新变量起名 `decision`,与同一个函数里后面那个 `const decision = await new Promise(...)` 撞名 ⇒ SyntaxError。上一轮的 `spawn` 撞名也是这一族(局部名与既有作用域重名),两次都是**语法检查/测试**立刻抓到。 ## 文档 `docs/PLAN.md` §7.11 的 L5 矩阵与"向更严取整"那条纪律、`docs/API.md` 的档位表 都改成新语义(有沙箱=内核拒、无沙箱=逐条问),并写明 pi 的沙箱为什么必须由宿主提供。 --- docs/API.md | 4 ++ docs/PLAN.md | 9 ++- plugins/pi-mail-bridge/lib/sandbox.js | 57 ++++++++++++++++- plugins/pi-mail-bridge/src/pool.mjs | 6 ++ plugins/pi-mail-bridge/src/worker.mjs | 36 ++++++++++- .../test/sandbox-launch.test.mjs | 63 ++++++++++++++++++- 6 files changed, 167 insertions(+), 8 deletions(-) diff --git a/docs/API.md b/docs/API.md index acf7140..c950b45 100644 --- a/docs/API.md +++ b/docs/API.md @@ -193,6 +193,10 @@ PUT /sessions/{id}/permission 改档位 {permission_mode}(读取走会话详 |---|---|---| | `plan` | 只读:查资料、读代码、出方案,一个字都不许写 | **不产生** —— 直接拒绝,模型把方案写在回信里 | | `workspace` | 本目录内可动,越界要问人(**默认档**) | 越界时产生 | + +> **收件平台有内核沙箱时(pi + `am-sandbox`),这一档的"越界"由内核直接拒绝** +> (EACCES),不再产生询问邮件:"界内不问、界外拒绝"。没有沙箱的平台/机器上仍是 +> 逐条问人(**不会更松**)。两种情况都会在会话上如实上报实际强制力。 | `full` | 自动放行 | **不产生** —— 已声明全权,再问是噪音 | - 新建会话时在发信请求体里带 `permission_mode` 声明初始档位(省略 = `workspace`) diff --git a/docs/PLAN.md b/docs/PLAN.md index 6863f19..d8e17d0 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1446,8 +1446,13 @@ plan/workspace/full **一一对应** —— 不是巧合,是同一个问题的 否则发一封 `mode=full` 的信就自我提权了。继承保证 plan 档派不出 full 档子任务 —— 与 `hop_limit` 同形:约束必须沿链条传递。 3. **平台表达不出精确档位时向更严取整,并如实上报实际强制力**。pi 的 bash 在 - workspace 档只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整, + workspace 档曾只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整, 而往宽松取整是静默失效(人以为收紧了,实际没有)。 + **2026-09-14 补**:pi 这一格后来接上了宿主层沙箱(`server/cmd/am-sandbox`, + Landlock)——「界内/界外」改由**内核**判,于是 workspace 档终于可以说"界内不问、 + 界外拒绝",不必再逐条问人。pi 自己不内置沙箱是有意的(docs/security.md: + 进程内的部分沙箱会被误解成安全边界),所以这一层必须由跑它的宿主提供 —— + 我们的桥原先没提供,那才是缺口。 4. **`agents.mode_enforcement`(心跳自报 native/advisory)**。homeagent 是 advisory —— 发件人以为 plan 档管住了它,实际管不住。两个字段(要求档位 / 实际强制力)都要 上界面,差异可见才符合 `I-5`。 @@ -1567,7 +1572,7 @@ plan/workspace/full **一一对应** —— 不是巧合,是同一个问题的 | 平台 | plan | workspace | full | enforcement | |---|---|---|---|---| -| **pi** | ✅ bash block(无权限询问) | ✅ bash 一律问人(无法静态判路径) | ✅ bash 直接执行 | native | +| **pi** | ✅ bash block(无权限询问) | ✅ **有沙箱**:界内不问、界外内核拒(Landlock);**无沙箱**:回退逐条问人 | ✅ bash 直接执行 | native | | **opencode** | ✅ briefing 生效,模型自愿遵守 | ✅ briefing 生效 | ✅ briefing 生效 | advisory | | **dsh** | ✅ 写文件真被拦(read-only 沙箱;echo 命令仍可跑) | ✅ 目录内可写;/tmp 属允许临时目录不询问 | ✅ danger-full-access | native | | **homeagent** | ⚠️ 只在提示词告知,模型不遵守 | ⚠️ 只在提示词告知 | ⚠️ 只在提示词告知 | advisory | diff --git a/plugins/pi-mail-bridge/lib/sandbox.js b/plugins/pi-mail-bridge/lib/sandbox.js index 9a4e077..34beec8 100644 --- a/plugins/pi-mail-bridge/lib/sandbox.js +++ b/plugins/pi-mail-bridge/lib/sandbox.js @@ -41,6 +41,55 @@ import { join } from 'node:path'; /** 边界工具的默认位置(由 redeploy-gateway.sh / install.sh 安装)。 */ export const DEFAULT_SANDBOX_BIN = '/opt/agentmail/bin/am-sandbox'; +/** + * 档位 × 有没有沙箱 → 被守卫的工具(bash/write/edit)该怎么办。 + * + * | 档位 | 沙箱 | 决定 | + * |---|---|---| + * | full | 任意 | `allow`(发件人已声明全权) | + * | plan | 任意 | `block`(本档只许看;沙箱是第二层,但先把话说清楚更快) | + * | workspace | **在** | `allow` —— **这是这一步改的东西** | + * | workspace | 不在 | `ask`(回退到原来的逐条问人) | + * + * 为什么 workspace 档在有沙箱时可以不问:档位的语义是「本目录内可动,越界要问人」, + * 而**内核已经把"越界"变成了 EACCES** —— 再问一遍只是让人点一次"同意",点完 + * 该失败的还是失败(人点了同意也挡不住内核)。真正的语义由 am-sandbox 承担: + * 界内:不问(内核保证出不去) + * 界外:不用问,直接失败,模型从错误里知道"要越界得走 full 档或请人执行" + * 而**没有沙箱时必须继续问** —— 那才是原来唯一的一层。 + */ +export function guardDecision({ mode, sandboxed, toolName, guarded }) { + if (String(mode) === 'full') return 'allow'; + if (!guarded) return 'pass'; + if (String(mode) === 'plan') return 'block'; + return sandboxed ? 'allow' : 'ask'; +} + +/** + * 自证:标记说"套了沙箱",但真的套上了吗? + * + * 为什么不只信 `AGENTMAIL_PI_SANDBOXED`:那只是父进程设的一个环境变量。父进程 + * 判断错(或有人手改 env)会让闸门以为"内核兜住了"而放行 —— 那是最坏的一类错 + * (既不问、也不拦)。所以现场真写一个**一定在界外**的金丝雀文件: + * 写得进去 ⇒ 没有沙箱(退回逐条问人,方向取严) + * 被拒 ⇒ 有沙箱(EACCES/EROFS/EPERM 都算) + * + * 金丝雀路径取 `/`:会话工作区与 `os.tmpdir()` 都在 rw 里,只有根目录永远不在。 + */ +export function verifySandboxActive({ + canaryPath = `/.agentmail-sandbox-canary-${process.pid}`, + writeFile, unlink, +} = {}) { + try { + writeFile(canaryPath, 'x'); + } catch (e) { + const code = e?.code || ''; + return { active: true, reason: `界外写入被拒(${code || e?.message || 'error'})` }; + } + try { unlink(canaryPath); } catch { /* 尽力清理,失败不影响结论 */ } + return { active: false, reason: '**能写界外** —— 沙箱没生效(退回逐条问人)' }; +} + /** * 取沙箱二进制路径;返回空串表示"不套"。 * @@ -103,11 +152,15 @@ export function workerLaunch({ if (!bin) return direct('没有可用的 am-sandbox(未安装 / 被显式关掉 / 路径不存在)'); if (!cwd || !exists(cwd)) return direct(`拿不到会话工作区(cwd=${cwd || '空'})—— 不猜`); - const { dirs, files } = sandboxWritePaths({ cwd, env, exists }); + // plan 档 = "一个字都不许写":连会话工作区都不给写权限(沙箱是第二层, + // 第一层是 worker 里那道 block)。workspace 档才把工作区放进 rw。 + const { dirs, files } = sandboxWritePaths({ + cwd: String(mode) === 'plan' ? '' : cwd, env, exists, + }); const argv = []; for (const d of dirs) argv.push('--rw', d); for (const f of files) argv.push('--rw-file', f); argv.push('--', nodePath, workerPath); - return { cmd: bin, argv, sandboxed: true, reason: `rw=${dirs.join(',')}` }; + return { cmd: bin, argv, sandboxed: true, rw: dirs, reason: `rw=${dirs.join(',')}` }; } diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index cc6f563..257cecf 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -205,6 +205,12 @@ export function createWorkerPool({ } const child = spawnProcess(launch.cmd, launch.argv, { + // 套沙箱时给子进程一个标记(闸门据此决定还要不要逐条问人)。 + // ★ 标记只是"父进程认为套上了";worker 会**自证**(往界外写金丝雀), + // 两处不一致时取严(继续问人)。 + env: launch.sandboxed + ? { ...env, AGENTMAIL_PI_SANDBOXED: '1', AGENTMAIL_PI_SANDBOX_RW: (launch.rw || []).join(':') } + : env, // stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。 // 'ipc' 必须显式列出,否则 process.send 不存在。 stdio: ['ignore', 'inherit', 'inherit', 'ipc'], diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index 18b01b4..b4b43bb 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -56,6 +56,8 @@ import { autoRelayDecision } from '../lib/relay-policy.js'; import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js'; import { normalizeMode, MODE_FULL, MODE_PLAN } from '../lib/permission-mode.js'; +import { guardDecision, verifySandboxActive } from '../lib/sandbox.js'; +import { writeFileSync, unlinkSync } from 'node:fs'; import { clampRelayKey, isPermanentFailure, isDuplicateRelay } from '../lib/relay-key.js'; // ─── 与主进程的通道 ─── @@ -114,6 +116,27 @@ let finished = false; * 决策等待期间**只有这个 worker 停住**,主进程照常读 SSE、照常给别的会话 * 派活 —— 这正是原来最难受的一处:权限询问会让整座桥不再收信。 */ +/** + * 这个 worker 是不是真跑在沙箱里。 + * + * 父进程只设标记(`AGENTMAIL_PI_SANDBOXED=1`),但**标记不等于事实** —— + * 判断错会让闸门既不问也不拦(最坏的一类)。所以现场自证一次:往界外写一个 + * 金丝雀,写不进去才算数(见 lib/sandbox.js 的 verifySandboxActive)。 + * + * 结果缓存在进程级:自证会在界外留一个瞬时文件,不值得每轮重来。 + */ +let sandboxState = null; +function sandboxActive() { + if (sandboxState) return sandboxState.active; + if (process.env.AGENTMAIL_PI_SANDBOXED !== '1') { + sandboxState = { active: false, reason: '父进程没标记(这一轮没套沙箱)' }; + } else { + sandboxState = verifySandboxActive({ writeFile: writeFileSync, unlink: unlinkSync }); + } + log(`沙箱自证:${sandboxState.active ? '在边界内' : '不在边界内'} —— ${sandboxState.reason}`); + return sandboxState.active; +} + function permissionExtension() { const GUARDED = new Set(['bash', 'write', 'edit']); @@ -124,14 +147,21 @@ function permissionExtension() { // full: 不拦截(已声明全权) // workspace: 走原有问人流程 const mode = normalizeMode(mailContext.permissionMode); - if (mode === MODE_FULL) return; // full 档不拦任何工具 - if (mode === MODE_PLAN && GUARDED.has(event.toolName)) { + // 决策收在 lib/sandbox.js 的 guardDecision 里(纯函数,可单测): + // full → 放行;plan → 拒;workspace + **沙箱在** → 放行(内核兜住边界); + // workspace + 没沙箱 → 逐条问人(回退到原来那唯一一层)。 + // 这一段以前只有"一律问",因为那时没有任何东西能判界内/界外。 + const gate = guardDecision({ + mode, sandboxed: sandboxActive(), toolName: event.toolName, + guarded: GUARDED.has(event.toolName), + }); + if (gate === 'pass' || gate === 'allow') return; + if (gate === 'block') { return { block: true, reason: `plan 档下不允许执行 ${event.toolName}。本档只允许读与查,请把方案写在回信里。如需动手请让发件人把档位改成 workspace。`, }; } - if (!GUARDED.has(event.toolName)) return; const sid = ctx?.sessionManager?.getSessionId?.() || ''; // 只管自己那条会话。worker 里不该出现第二条,出现了说明有 bug —— diff --git a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs index fb2687e..42f144e 100644 --- a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs +++ b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs @@ -11,12 +11,18 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { DEFAULT_SANDBOX_BIN, sandboxBin, sandboxWritePaths, workerLaunch } from '../lib/sandbox.js'; +import { + DEFAULT_SANDBOX_BIN, guardDecision, sandboxBin, sandboxWritePaths, verifySandboxActive, workerLaunch, +} from '../lib/sandbox.js'; // 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。 const fsWith = (paths) => (p) => paths.includes(p); +const HERE = dirname(fileURLToPath(import.meta.url)); const NODE = '/usr/bin/node'; const WORKER = '/opt/agentmail/plugins/pi-mail-bridge/current/src/worker.mjs'; const CWD = '/home/program/agentmail'; @@ -85,3 +91,58 @@ test('rw 只收**存在**的路径(am-sandbox 对不存在的 rw fail closed const dup = sandboxWritePaths({ cwd: '/tmp', env: { TMPDIR: '/tmp' }, exists, home: HOME }); assert.equal(dup.dirs.filter((d) => d === '/tmp').length, 1, 'rw 目录要去重'); }); + +// ─── 闸门决策(这一步改的就是它)─── + +test('★ 档位 × 沙箱 → 被守卫工具该怎么办', () => { + const T = (mode, sandboxed) => guardDecision({ mode, sandboxed, toolName: 'bash', guarded: true }); + + // 有沙箱:workspace 档**不再逐条问人** —— 界内不问、界外由内核拒。 + // 这一条是"被问 15 次"那个体验的终点;改回 'ask' 会立刻红。 + assert.equal(T('workspace', true), 'allow', '有沙箱时 workspace 档不该再问人'); + // 没有沙箱:必须继续问 —— 那是原来唯一的一层。 + assert.equal(T('workspace', false), 'ask', '没有沙箱时必须逐条问人(回退到原来的行为)'); + // full 档:任意情况下都放行(含没有沙箱时)。 + assert.equal(T('full', false), 'allow'); + assert.equal(T('full', true), 'allow'); + // plan 档:一律拒(沙箱是第二层,先把话说清楚更快)。 + assert.equal(T('plan', true), 'block'); + assert.equal(T('plan', false), 'block'); + // 不被守卫的工具(read/grep/find/ls):不拦。 + assert.equal(guardDecision({ mode: 'workspace', sandboxed: false, toolName: 'read', guarded: false }), 'pass'); +}); + +test('★ 沙箱自证:写得进界外 ⇒ 判定为"没有沙箱"(方向取严)', () => { + const blocked = verifySandboxActive({ + canaryPath: '/virtual/canary', + writeFile: () => { const e = new Error('permission denied'); e.code = 'EACCES'; throw e; }, + unlink: () => {}, + }); + assert.equal(blocked.active, true, '写界外被拒 = 在边界内'); + assert.match(blocked.reason, /EACCES/); + + const leaked = verifySandboxActive({ + canaryPath: '/virtual/canary', + writeFile: () => {}, + unlink: () => {}, + }); + assert.equal(leaked.active, false, '能写界外就必须判为"没沙箱" —— 否则闸门会既不问也不拦'); +}); + +test('★ plan 档的 rw 里没有会话工作区("一个字都不许写")', () => { + const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, `${HOME}/.agentmail`, '/dev/null']); + const plan = workerLaunch({ mode: 'plan', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + assert.equal(plan.sandboxed, true, 'plan 档也要套沙箱(第二层)'); + assert.ok(!plan.rw.includes(CWD), `plan 档的 rw 不该含会话工作区:${plan.rw.join(',')}`); + + const ws = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + assert.ok(ws.rw.includes(CWD), 'workspace 档必须含会话工作区(否则界内也写不了)'); +}); + +test('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => { + const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8'); + assert.ok(src.includes("AGENTMAIL_PI_SANDBOXED: '1'"), 'pool 要在套沙箱时设标记'); + const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8'); + assert.match(worker, /verifySandboxActive\(/, 'worker 必须现场自证,而不是只信标记'); + assert.match(worker, /guardDecision\(/, '闸门必须走 guardDecision'); +});