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

@ -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 ——