feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)

L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -1401,6 +1401,174 @@ MVP 计划Phase 1-6已全部落地并在 systemd 部署态实测通过。
点下去命中谁」,而这次最严重的 bug 恰好只有 `elementFromPoint` 能发现
它不进 `npm test` —— 要一个跑着的浏览器加一个活的 Gateway
### 7.11 权限档位plan / workspace / full
人在派活时声明这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的
沙箱/审批配置三档
| 档位 | 语义 | 权限询问 |
|---|---|---|
| `plan` | 只读查资料读代码出方案一个字都不许写 | **不产生** —— 直接拒绝模型该把方案写在回信里 |
| `workspace` | 本目录内可动手越界要问人**默认档** | 越界时产生 |
| `full` | 自动放行 | **不产生** —— 已声明全权再问是噪音 |
#### 为什么需要它
此前根本没有权限模型能不能跑 bash 完全由各平台自己的本地配置决定
dsh `settings.yaml`opencode `opencode.jsonc`pi 硬编码守卫三个工具名)。
发件人对此毫无控制也毫不知情 —— 派一件只是看一下的活对方可能直接改文件
#### 架构AgentMail 声明,平台执行,插件只翻译
**不让插件按工具名自己猜着拦**那会同时违反 `I-1`平台原生信号是唯一真相来源
`I-4`插件只搬运不决策而且四个插件对workspace 到底管什么必然各猜一套 ——
同一封 workspace 档的邮件在 A 平台被拦 B 平台放行
平台原生能力调研决定了整个架构
| 平台 | 原生机制 | 能否只读 | 能否管目录边界 |
|---|---|---|---|
| dsh | `setSandboxMode(session, mode)`三档写死 | 真沙箱 | 真沙箱 |
| opencode | `session.create({permission:[…]})`action allow/ask/deny | | glob |
| pi | 只有 `tool_call` 钩子 `{block:true}` | 按工具名 | write/edit 能查 `input.path`**bash 不能** |
| homeagent | **无任何拦截点** | 不能 | 不能 |
dsh 原生三档`read-only` / `workspace-write` / `danger-full-access`
plan/workspace/full **一一对应** —— 不是巧合是同一个问题的同一个答案
#### 五条设计决定
1. **档位挂 `sessions.permission_mode`,不挂每封邮件**与配额同理它是**任务**
属性续谈的信若也能带档位每封新信都会悄悄改掉对方正在遵守的规则 ——
plan 档的会话里模型已被告知只许看」,第二封信改成 full 是在一段已有
上下文里换规则新建时设续谈忽略对话页里显式编辑
2. **Agent 不能自己指定档位,新会话从父会话继承**`ModeAtMost(父档, 请求档)`)。
否则发一封 `mode=full` 的信就自我提权了继承保证 plan 档派不出 full 档子任务 ——
`hop_limit` 同形约束必须沿链条传递
3. **平台表达不出精确档位时向更严取整,并如实上报实际强制力**pi bash
workspace 档只能退回每条都问人」。不定这条规则四个插件会朝不同方向取整
而往宽松取整是静默失效人以为收紧了实际没有)。
4. **`agents.mode_enforcement`心跳自报 native/advisory**。homeagent advisory ——
发件人以为 plan 档管住了它实际管不住两个字段要求档位 / 实际强制力都要
上界面差异可见才符合 `I-5`
5. **只有 workspace 档需要人**这一条直接决定找不到人类时怎么办」:plan 档当场
拒绝full 档自动放行两者都不问人所以只有 workspace 档会走到这条链上有没有
人类」,找不到就是 409
#### 顺带修掉的两处死代码
- **`permission.go` 的管理员兜底吃掉了 409 分支**。原顺序是 `req.To 会话 owner
第一个 active admin IsHuman`,第三步让第四步永远为真,`NearestHumanInThread`
与那段 409 从未被执行。实测确认pi 给自己新开会话派活跑 bash权限邮件
`to_name=jianf`,点同意后真跑了。而那段 409 的注释本身就在论证兜底是错的
(「管理员对这条 Agent 链的上下文一无所知」)—— 两条策略互相矛盾,先执行的那条
把后写的那条变成了死代码。删掉兜底后 `repo.FirstAdminUsername` 也随之失去唯一
调用点,一并删除。
- **`repo.ListSessions` 从初始提交就是坏的**SELECT 9 列、`Scan` 11 个参数,零调用点。
与 `ListSessionsFor` 当年真出过的事故同一个坑(加了预算两列没加进 Scan
`/me/sessions` 整个 500。留着就得给它也加档位两列等于维护一个坏且没人用的
东西,删掉更诚实。
#### 实测发现opencode 的六条,不实测就会做出「看起来对但管不住」的东西)
`session.create({permission:[…]})` 确实生效,但:
1. **规则是 `findLast` 胜出** → **deny 必须放前面、allow 放后面**。反了的话连
本该允许的路径也被拒(第一版就写反了,模型自己报「按规则本该通过但实际被拒」)。
2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree, file)]`)→
写 `/tmp/**` 这种绝对 pattern **永远匹配不上**。这条最隐蔽:配置看着对,全不生效。
3. **write / edit / patch 共用 `edit` 一个权限名**。
4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool available
in this session」部分 deny 则工具保留、越界调用才报错。plan 档用前者更好:
模型不会浪费轮次去试。
5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write**主动委派给
一个带 write 的子代理写成了**。plan/workspace 必须 `task deny *`。
6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 deny 的文件。
所以 workspace 档必须同时管 bash只管 edit 没用。
opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档**撞名但语义
不同**(那是它自己的计划模式开关),不碰。
附带收益dsh 本机配的是 `danger-full-access` → `approval: "never"`,而
`ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
**在 waterfall 之前短路** —— 所以整个「权限转邮件」链路在 dsh 上从未真正跑起来过。
按档位下发 `sandbox/mode` 后workspace 档的会话才会拿到 `approval: ask`。
#### 已完成
- [x] `models/permission_mode.go`:三档常量、`NormalizePermissionMode`(非法值
fail-closed 到默认档而非 full、`ModeAtMost`(继承与取整共用一个判据)、
`ModeNeedsHuman`、native/advisory 强制力。12 例测试 + 2 组负向对照
- [x] schema 三处同步:`sessions.permission_mode`(旧库默认 workspace不追授全权
`sessions.permission_enforcement`(旧库默认 advisory不替没自报的插件宣称
「档位在这里是被强制的」)、`agents.mode_enforcement`
- [x] `repo/permission_mode.go`:读写 + `InheritedMode` 继承 + `AgentModeEnforcement`
- [x] 读路径三处加列:`GetSessionByID` / `ListSessionsFor` / `ListContactsFor`
- [x] `me.go` 人发信可指定档位(人是权限的源头);非法值报 400 而不是静默用默认档
—— 他以为给了 plan 实际拿到 workspace比报错更坏
- [x] `permission.go` 按档位决定这次询问该不该存在删管理员兜底409 恢复可达
- [x] 日历会话给人类创建者设 owner否则删掉兜底后人建的提醒触发时 Agent 的
权限询问会因发件人是 `calendar` 而在线索上找不到人类 → 误伤成 409
- [x] SSE 与补拉路径下发 `permission_mode` / `permission_enforcement`
(补拉路径必须有:否则 plan 档的任务在插件重启后悄悄变成 workspace 档)
- [x] `lib/permission-mode.js` 四平台翻译表(三方逐字节相同,已纳入
`check-shared-libs.sh`。32 例测试 + 4 组负向对照,六条实测结论逐条钉死
#### P0判据错误不修则以上代码失效
- [ ] **`parentMailID == nil` 不等于「新建会话」** —— 省略 session 位复用默认会话时
它也是 nil。实测第一封 `max_rounds=7` → 第二封省略该字段 → **预算被冲成 20**。
这是**预存 bug**(配额那段注释正在论证这不该发生,守卫写错了),而我的
`permission_mode` 抄了同一个守卫 —— 第二封信会静默把 plan 档改成 workspace。
修法:`resolveTarget` 返回 `created bool`,只有真新建才设预算与档位
- [ ] **四个插件 `send_mail` 补传 `from_session_id`** —— 否则 `InheritedMode` 永远走
回落分支,继承是假的。这是唯一可靠来源:一个 Agent 可同时有多条活跃会话,
服务端猜不出它此刻属于哪条
#### P1三条建会话路径漏设档位
- [ ] `forward.go` 转发 —— `LoadForwardSource` 已返回源邮件(含 `SessionID`
用 `InheritedMode(&src.SessionID, 默认档)`。不修则 plan 档转发出去就升到 workspace
- [ ] `calendar_events` 加列 `permission_mode`schema 三处同步)+ 日历投递接线:
- 人建日程可指定;**Agent 建日程用它当时所处会话的档位定死,不许自选**
- 投递时新建会话 → 用事件档位;**复用会话 → `ModeAtMost(会话现档, 事件档)`**
取更严,不能因复用而提权
- 堵住提权路径plan 档的 Agent 建一个日程,触发时新会话拿默认档 workspace ——
它绕过 plan 档去写文件了,只是延迟了几分钟
- [ ] adopt 接管平台会话 —— 无父会话,用默认档,在 `AdoptPlatformSession` 内显式写入
而不是靠 DB 默认值
#### P2数据出不去
- [ ] `GetSessionMails`(会话视图)与 `ListSentBy`(发件箱)的 `models.Mail` 补两列 ——
只改了 `ListInbox`,前端要显示档位徽标时这两条路径拿不到值
- [ ] `PUT /sessions/{id}/permission` 端点 —— 已在 `me.go` 注释里引用但未实现,
对话页要靠它改档
#### P3测试
- [ ] `repo/permission_mode_test.go`:继承、取更严、脏值回落
- [ ] handler 档位判定 + 409 可达性(负向对照:恢复管理员兜底 → 用例必须失败)
- [ ] `notify` 两个新字段(挂真实 SSE 客户端读帧,沿用 `notify_test.go` 现有手法)
- [ ] 预算不被冲的回归用例(钉住 P0 第一项)
#### P4插件接线
- [ ] dsh`setSandboxMode` + `setPolicy`;附带让上一轮那个 `tools/post-execute`
修复第一次真正可测(此前因 `approval:"never"` 短路而永远走不到)
- [ ] opencode`session.create({permission})`,按六条实测结论下发
- [ ] pi`piGuardedTools` 按档位决定拦哪些工具plan 档直接拒绝不问人
- [ ] homeagentGo 对应物 `permission_mode.go` + advisory 提示词
**必须如实说「这个平台无法强制这一档」** —— 假装是强制的会让模型以为越界
会被拦,于是不必自己小心,那比做不到本身更危险)
#### P5前端与文档
- [ ] types / API / 卡片档位徽标 / 对话页档位选择器 / 新建邮件档位选择
(两个字段成对显示:要求档位 + 实际强制力)
- [ ] `docs/PLUGIN-CONTRACT.md` 新章节 + 平台差异表补一行
---
已知取舍,尚未处理:
@ -1415,6 +1583,91 @@ MVP 计划Phase 1-6已全部落地并在 systemd 部署态实测通过。
---
### 7.12 全面修正:让平台真正可用(本轮)
本轮起因是排查附件链路,结果连带挖出四类问题。它们的共同形状是**静默成功** ——
请求返回 200、日志干净、界面看着正常而实际的事没有发生。这类 bug 能活很久,
因为没人会去核对一个成功的请求。
#### A. 附件链路
| # | 问题 | 状态 |
|---|---|---|
| A-1 | homeagent 按**顶层** `attachment_id` 解上传响应,而服务端返回 `{"attachment":{…}}` → 三个字段全零值,模型看到 `id= filename= size=0KB` | 已修 |
| A-2 | homeagent 的 `send_mail` **根本没声明** `attachment_ids` 参数,提示词还教模型传 `attachments:[{…}]` | 已修 |
| A-3 | `size/1024` 让 800 字节的附件显示成 `0KB` | 已修(`formatSize` |
| A-4 | 挂载失败403/409时**邮件已入库、已通知、预算已扣** —— 收件方收到一封没有附件的邮件,发件方收到 4xx 以为没发出去 | 本轮修 |
| A-5 | 磁盘上 7 个 blob 没有任何库记录指向GC 永远扫不到(它只按库记录走) | 本轮修 |
A-1 + A-2 叠起来意味着 **homeagent 的附件发送从来没成功过一次**。
没有任何一层报错HTTP 200、文件落盘、库里登记只是那个 id 是空串,
24 小时后 GC 把没人引用的文件清掉,现场不留痕迹。
#### B. 请求解析:未知字段必须报错
A-2 之所以能活那么久,根因在服务端:`json.Decoder` 默认**忽略未知字段**。
```
$ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
HTTP 200 {"mail_id":"2a64fdc8…", …}
$ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
0
```
这是 `I-5`(失败必须当场可见)在请求解析层的落点。改法:`Decode` 打开
`DisallowUnknownFields`,并把**本端点接受的字段一并列出来** —— 只说「不认识 x」
的话,调用方仍要去翻服务端源码才知道对的拼法,而拼错字段名恰恰是最容易犯、
最难自查的错。
心跳是唯一的例外(`DecodeLenient`):那条路径的职责是「我还活着」,插件比服务端新、
多带一个字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。但**必须在
响应里回报** `unknown_fields`,否则又变成一次静默忽略。
严格化的爆炸半径已逐个核对(前端 30 个写端点 + 四桥所有 payload + demo 脚本 +
三种嵌套结构),只有一处真的会被打破:**日历创建端点缺 `status` 字段**
而前端 `CalendarEventEditor` 无条件发它。顺带把 `status` 的取值也校验上 ——
此前 update 端点接受任意字符串,写进库就成了一个调度器不认识的状态。
#### C. 人 / Agent 的区分在读路径上缺失
`addr-verify` 报的两项失败追下去是**数据完整性问题而非显示问题**
前端用 `mail.to_workspace ? ws : ''` 当「这一方是不是 Agent」的判据。
而 `to_workspace` 为空的 Agent 收件人有 **25/118 封**(人给 homeagent 发信、
权限决策回信、Agent 间转发…都不带 path 位),于是 `pi` 被渲染成裸名字、
会话别名跟到了人身上。
判据本身选错了。服务端早就有 `IsHumanUser`,也已经在收件箱列表路径上算过
`from_human`,但另外**四个读路径**(单封读、会话读、发件箱、会话内单封读)都没带。
补 `from_human` + 新增 `to_human`,前端改用它。
> 不能用 `session.from_agent` 代替:它的语义是「谁发起了这条会话」,
> 实测有 45 封邮件的收件 Agent 不等于 `from_agent`。
#### D. 「Agent 间不自动转发」没有写进契约
代码四桥齐全(`lib/relay-policy.js` + `relay_policy.go`),但 `docs/PLUGIN-CONTRACT.md`
里**只有共用模块表的一句括注**,没有规范条款。更糟的是 `B-3.4` 仍无条件写着
「提示词里写明回信由插件自动发」—— 与规则直接矛盾。照文档实现的新插件会做错。
连带三处:
- SSE `new_mail` 的字段表缺 `from_human` / `in_reply_to` /
`permission_mode` / `permission_enforcement`(四个都已在下发,文档没跟上)
- `deploy/remote-agent-demo.py` **无条件回信**,两个这样的 demo 对上就是
ping-pong只有会话预算能刹住
- 该脚本指向的 `docs/PLUGIN-GUIDE.md` 已在 `289f37f` 删除
#### 验收
- [ ] A-4附件挂载失败时邮件**不入库**(回滚),预算不扣,`relay_key` 归还
- [ ] A-5`blob.Store` 可枚举 + GC 反向扫盘,一次跑掉 7 个孤儿
- [ ] B`attachments` 这类拼错字段名返回 400 且列出正确字段;日历创建带 `status` 仍 200
- [ ] C`GET /mail/{id}` 返回 `from_human` / `to_human``addr-verify` 两项转绿
- [ ] D契约文档有 `B-5.6` 条款demo 按 `from_human` 决定是否回信
---
## 文件清单(完整)
```