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 的沙箱为什么必须由宿主提供。
This commit is contained in:
2026-09-14 23:50:35 +08:00
parent 64f002cf66
commit 9fa509844a
6 changed files with 167 additions and 8 deletions

View File

@ -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(',')}` };
}