沙箱上线后,"工作区档"的语义第一次可以按档位表兑现:**边界是内核在守**,再问一遍
只是让人点一次"同意",点完该失败的还是失败(人点了也挡不住内核)。所以闸门改成
**按档位 × 有没有沙箱** 决策,纯函数收在 `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 的沙箱为什么必须由宿主提供。
149 lines
8.1 KiB
JavaScript
149 lines
8.1 KiB
JavaScript
/**
|
||
* 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');
|
||
});
|