Files
MailUI4Agents/plugins/pi-mail-bridge/lib/sandbox.js
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

167 lines
7.7 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 怎么起:直接 node,还是先套一层 `am-sandbox`(Landlock)。
*
* # 这一步补的是「工作区档」缺的那一维
*
* 档位表写的是「本目录内可动、越界要问人」,而桥原先只能按**工具名**判
* (bash/write/edit 一律问人)—— 因为命令的影响范围无法从文本静态判定。沙箱把
* 「界内/界外」交给内核去判,于是这条档位第一次是真的:
*
* plan / workspace 档 → worker 跑在 `am-sandbox --rw <会话工作区> …` 里面,
* 界外写是 EACCES(不依赖模型配合、也不依赖人点得准)
* full 档 → **不套**(发件人已声明全权,与档位表一致)
*
* # 为什么 rw 里既有会话工作区、又有几个固定目录
*
* 沙箱只放行「写」,而且必须把**跑起来真正需要的写点**列全,否则 worker 自己会崩:
*
* - 会话工作区:agent 该动手的地方
* - 临时目录(`os.tmpdir()`):构建/工具链的日常写点(dsh 也是这么放的)
* - `/dev/null`:**任何 `cmd 2>/dev/null` 都是一次界外写** —— 实测漏了它会让
* 一整代命令集体 Permission denied。用 `--rw-file`(只放行写这个设备,
* 不放行在 /dev 里建/删任何东西)
* - pi 自己的会话登记(`<agentDir>/sessions`):不写它,回合结束保存会话就失败
* - 桥自己的配置目录(`AGENTMAIL_CONFIG_DIR`,默认 `~/.agentmail`):
* worker 会往里面写 `explicit-sends.jsonl`
*
* 这几条是**实测得出**的写点清单,不是想当然:少一条的症状是 worker 回合中途报
* EACCES/EROFS,而不是"沙箱没生效"。
*
* # 拿不到工作区就不套
*
* `--rw` 必须给真实目录,`am-sandbox` 对不存在的路径直接 fail closed(退出码 126)
* —— 那会让这条会话连 worker 都起不来。所以这里取不到 cwd 时**不猜**:退回不套沙箱,
* 由调用方把原因打进日志(不许静默)。
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
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: '**能写界外** —— 沙箱没生效(退回逐条问人)' };
}
/**
* 取沙箱二进制路径;返回空串表示"不套"。
*
* `AGENTMAIL_PI_SANDBOX` 显式给出时以它为准(`""` / `0` / `off` = 明确关掉);
* 没给时看默认位置在不在。**显式给了却不存在的路径**也算关掉,但要由调用方出声
* —— 静默降级成"裸跑"正是这套东西最不该有的行为。
*/
export function sandboxBin(env = process.env, exists = existsSync) {
const raw = env.AGENTMAIL_PI_SANDBOX;
if (raw !== undefined) {
const v = String(raw).trim();
if (v === '' || v === '0' || v.toLowerCase() === 'off') return '';
return exists(v) ? v : '';
}
return exists(DEFAULT_SANDBOX_BIN) ? DEFAULT_SANDBOX_BIN : '';
}
/**
* 沙箱要放行的写点。返回 `{ dirs, files }`。
*
* 只保留**存在**的目录/文件:`am-sandbox` 对不存在的 rw 路径 fail closed,
* 而这里宁可少放行也不要让 worker 起不来(少放行的症状是可诊断的 EACCES)。
*/
export function sandboxWritePaths({
cwd, env = process.env, exists = existsSync, tmp = undefined, home = undefined,
}) {
const dirs = [];
const files = [];
const pushDir = (d) => { if (d && exists(d) && !dirs.includes(d)) dirs.push(d); };
pushDir(cwd);
pushDir(tmp ?? (env.TMPDIR || '/tmp'));
const agentDir = env.PI_CODING_AGENT_DIR || join(home ?? homedir(), '.pi', 'agent');
pushDir(join(agentDir, 'sessions'));
pushDir(env.AGENTMAIL_CONFIG_DIR || join(home ?? homedir(), '.agentmail'));
const devNull = '/dev/null';
if (exists(devNull)) files.push(devNull);
return { dirs, files };
}
/**
* 决定这次的 worker 启动方式。
*
* @param {object} o
* @param {number|string} [o.mode] 会话档位(plan / workspace / full)
* @param {string} [o.cwd] 会话工作区(由调用方用与 worker 同一个函数解析)
* @param {string} o.nodePath node 可执行文件
* @param {string} o.workerPath worker 脚本
* @returns {{cmd: string, argv: string[], sandboxed: boolean, reason: string}}
*/
export function workerLaunch({
mode = 'workspace', cwd = '', nodePath, workerPath, env = process.env, exists = existsSync,
}) {
const direct = (reason) => ({ cmd: nodePath, argv: [workerPath], sandboxed: false, reason });
if (String(mode) === 'full') return direct('full 档:发件人已声明全权');
const bin = sandboxBin(env, exists);
if (!bin) return direct('没有可用的 am-sandbox(未安装 / 被显式关掉 / 路径不存在)');
if (!cwd || !exists(cwd)) return direct(`拿不到会话工作区(cwd=${cwd || '空'})—— 不猜`);
// 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, rw: dirs, reason: `rw=${dirs.join(',')}` };
}