Files
MailUI4Agents/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
JianFeeeee 9fa509844a feat(pi-bridge): 有沙箱时 workspace 档不再逐条问人 —— 界内不问、界外内核拒
沙箱上线后,"工作区档"的语义第一次可以按档位表兑现:**边界是内核在守**,再问一遍
只是让人点一次"同意",点完该失败的还是失败(人点了也挡不住内核)。所以闸门改成
**按档位 × 有没有沙箱** 决策,纯函数收在 `lib/sandbox.js`:

| 档位 | 沙箱 | 决定 |
|---|---|---|
| full | 任意 | allow(发件人已声明全权) |
| plan | 任意 | block(本档只许看;沙箱是第二层) |
| workspace | **在** | **allow** ← 这一步改的(界内不问、界外 EACCES) |
| workspace | 不在 | ask(回退到原来那唯一一层) |

没有沙箱时**继续问** —— 这条是"不会更松"的保证:沙箱缺失/未装/被关掉时行为与改前
逐字一致。

## 标记不等于事实:worker 自证

`AGENTMAIL_PI_SANDBOXED=1` 只是父进程的**声明**。判断错会让闸门既不问也不拦
(最坏的一类),所以 worker 现场自证一次:往界外写一个金丝雀(`/.agentmail-sandbox-canary-<pid>`,
根目录永远不在 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 的沙箱为什么必须由宿主提供。
2026-09-14 23:50:35 +08:00

149 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* worker 的**启动方式**按档位定:plan/workspace 套 Landlock 边界,full 档不套。
*
* # 为什么这条判据必须是"行为"而不是"文件里有那行字"
*
* 接线错的方式很安静:
* - 忘了把会话工作区放进 `--rw` ⇒ **界内也写不了**,worker 回合中途报 EACCES;
* - 把 full 档也套上 ⇒ 发件人声明了全权却被内核拦,行为与档位表相反;
* - 拿不到 cwd 时"猜一个目录" ⇒ 沙箱放行的是 A、worker 落在 B,最难查的那一类。
* 所以这里直接调 `workerLaunch`,断言它给出的**命令行**是什么。
*/
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, 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';
const HOME = '/root';
const ENV = { TMPDIR: '/tmp', HOME };
test('★ workspace 档:套沙箱,且会话工作区在 rw 里', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, `${HOME}/.agentmail`, '/dev/null']);
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, true, `workspace 档应当套沙箱(reason=${l.reason})`);
assert.equal(l.cmd, DEFAULT_SANDBOX_BIN);
// 会话工作区必须在 rw 里 —— 少了它,界内也写不了
const rwIdx = l.argv.indexOf(CWD);
assert.ok(rwIdx > 0 && l.argv[rwIdx - 1] === '--rw', `--rw 里必须有会话工作区:${l.argv.join(' ')}`);
// /dev/null 用 --rw-file(只放行写这个设备),否则 `cmd 2>/dev/null` 全线失败
const dnIdx = l.argv.indexOf('/dev/null');
assert.ok(dnIdx > 0 && l.argv[dnIdx - 1] === '--rw-file', `--rw-file 里必须有 /dev/null:${l.argv.join(' ')}`);
// 命令要在 `--` 之后,且是 node + worker
const sep = l.argv.indexOf('--');
assert.deepEqual(l.argv.slice(sep + 1), [NODE, WORKER], '`--` 之后应当是 node 与 worker 脚本');
});
test('★ full 档:不套(发件人已声明全权)', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', '/dev/null']);
const l = workerLaunch({ mode: 'full', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, false);
assert.deepEqual(l.argv, [WORKER], 'full 档必须直接起 worker');
assert.match(l.reason, /full/);
});
test('★ 拿不到会话工作区时不套,且理由说得出(不猜目录)', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, '/tmp', '/dev/null']);
const l = workerLaunch({ mode: 'workspace', cwd: '', nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, false, '没有 cwd 时必须退回不套 —— 猜一个 rw 会让界内也写不了');
assert.match(l.reason, /工作区/);
});
test('没有沙箱二进制时不套(但理由是明确的)', () => {
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists: fsWith([CWD]) });
assert.equal(l.sandboxed, false);
assert.match(l.reason, /am-sandbox/);
});
test('AGENTMAIL_PI_SANDBOX:显式关掉 / 显式指路', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, '/var/tmp/my-sandbox', CWD, '/tmp', '/dev/null']);
// 显式关:即使默认装好了也不套
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: 'off' }, exists), '');
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '0' }, exists), '');
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: ' ' }, exists), '');
// 显式指路:用给的那个
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/var/tmp/my-sandbox' }, exists), '/var/tmp/my-sandbox');
// 显式给了但不存在 ⇒ 不套(由调用方把理由打进日志,不静默裸跑)
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/nope' }, exists), '');
// 没给 ⇒ 看默认位置
assert.equal(sandboxBin({}, exists), DEFAULT_SANDBOX_BIN);
assert.equal(sandboxBin({}, fsWith([])), '');
});
test('rw 只收**存在**的路径(am-sandbox 对不存在的 rw fail closed)', () => {
const exists = fsWith(['/tmp', '/dev/null']); // sessions/config 都不在
const { dirs, files } = sandboxWritePaths({ cwd: '/nope', env: ENV, exists, home: HOME });
assert.deepEqual(dirs, ['/tmp'], '不存在的目录不该进 rw');
assert.deepEqual(files, ['/dev/null']);
// 去重:同一个目录给两次会让 argv 里出现重复的 --rw(无害但说明来源没收敛)
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');
});