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

@ -193,6 +193,10 @@ PUT /sessions/{id}/permission 改档位 {permission_mode}(读取走会话详
|---|---|---|
| `plan` | 只读:查资料、读代码、出方案,一个字都不许写 | **不产生** —— 直接拒绝,模型把方案写在回信里 |
| `workspace` | 本目录内可动,越界要问人(**默认档**) | 越界时产生 |
> **收件平台有内核沙箱时(pi + `am-sandbox`),这一档的"越界"由内核直接拒绝**
> (EACCES),不再产生询问邮件:"界内不问、界外拒绝"。没有沙箱的平台/机器上仍是
> 逐条问人(**不会更松**)。两种情况都会在会话上如实上报实际强制力。
| `full` | 自动放行 | **不产生** —— 已声明全权,再问是噪音 |
- 新建会话时在发信请求体里带 `permission_mode` 声明初始档位(省略 = `workspace`)

View File

@ -1446,8 +1446,13 @@ plan/workspace/full **一一对应** —— 不是巧合,是同一个问题的
否则发一封 `mode=full` 的信就自我提权了。继承保证 plan 档派不出 full 档子任务 ——
与 `hop_limit` 同形:约束必须沿链条传递。
3. **平台表达不出精确档位时向更严取整,并如实上报实际强制力**。pi 的 bash 在
workspace 档只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整,
workspace 档曾只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整,
而往宽松取整是静默失效(人以为收紧了,实际没有)。
**2026-09-14 补**:pi 这一格后来接上了宿主层沙箱(`server/cmd/am-sandbox`,
Landlock)——「界内/界外」改由**内核**判,于是 workspace 档终于可以说"界内不问、
界外拒绝",不必再逐条问人。pi 自己不内置沙箱是有意的(docs/security.md:
进程内的部分沙箱会被误解成安全边界),所以这一层必须由跑它的宿主提供 ——
我们的桥原先没提供,那才是缺口。
4. **`agents.mode_enforcement`(心跳自报 native/advisory)**。homeagent 是 advisory ——
发件人以为 plan 档管住了它,实际管不住。两个字段(要求档位 / 实际强制力)都要
上界面,差异可见才符合 `I-5`。
@ -1567,7 +1572,7 @@ plan/workspace/full **一一对应** —— 不是巧合,是同一个问题的
| 平台 | plan | workspace | full | enforcement |
|---|---|---|---|---|
| **pi** | ✅ bash block(无权限询问) | ✅ bash 一律问人(无法静态判路径) | ✅ bash 直接执行 | native |
| **pi** | ✅ bash block(无权限询问) | ✅ **有沙箱**:界内不问、界外内核拒(Landlock);**无沙箱**:回退逐条问人 | ✅ bash 直接执行 | native |
| **opencode** | ✅ briefing 生效,模型自愿遵守 | ✅ briefing 生效 | ✅ briefing 生效 | advisory |
| **dsh** | ✅ 写文件真被拦(read-only 沙箱;echo 命令仍可跑) | ✅ 目录内可写;/tmp 属允许临时目录不询问 | ✅ danger-full-access | native |
| **homeagent** | ⚠️ 只在提示词告知,模型不遵守 | ⚠️ 只在提示词告知 | ⚠️ 只在提示词告知 | advisory |