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:
253
docs/PLAN.md
253
docs/PLAN.md
@ -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 档直接拒绝不问人
|
||||
- [ ] homeagent:Go 对应物 `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` 决定是否回信
|
||||
|
||||
---
|
||||
|
||||
## 文件清单(完整)
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user