|
|
095213b981
|
fix(安全)★★: 声明别人的 session_id 就能读那封信 —— 补「参与方」判据
## 漏洞(实测,生产可利用)
对照实验(同一会话、同一 Agent,绕开 MCP 直打原生端点):
主人 mcp-peer 读自己的信(带正确 session_id) ⇒ 200(正常)
他人 mcp-probe 读同一封信(**带正确 session_id**) ⇒ 200 ★ 泄露正文
绕开 MCP、直接 `GET /api/v1/agent/mail/{id}?session_id=...` 同样 200
⇒ 根因在网关,不在任何接入方式。
生产复核(现行 8180,未改任何代码):
dsh 声明 gui-lab 的 session_id ⇒ HTTP 200,拿到完整正文
## 根因:判据里没有「你是谁」这一项
上一版(今天早些时候,15e4fe9 那次)只把 `if scope == nil { return true }`
改成拒绝,堵住的是「**不声明** session_id 就放行」。剩下的半边是
「声明一个**别人的** session_id」。
判据只有一句 `*scope != target` —— 它问的是「你声明的会话是不是目标会话」,
而 `session_id` **由请求方自己给**。于是任何持有 Agent 凭据的客户端只要报出
一个已存在的会话 id,就能以那条会话的身份读它。
漏洞的形状就写在签名里:函数**收了** `agentName`,却被 `_ = agentName` 丢弃。
## 「信任边界在桥」这个前提不成立
函数头原来写着「信任边界在**桥**:`session_id` 由 worker 闭包注入(模型改不了)」。
那是对我们自家四个桥的陈述,**不是**服务端能强制的事实:
1. 桥与网关之间是普通 HTTP。任何拿到 Agent 凭据的客户端都能直接调这些端点
(本次实测即是如此)。
2. 「由闭包注入」是对我们自己代码的信心,不是收到请求时能重新验证的事实。
上一版收口时也用过同类理由(「迁移期未结束」),实测同样不成立 ——
这是同一天内第二次。
## 修法:把「声明」变成可验证的事实
保留 `*scope != target`(2026-09-15 裁定:会话是独立单位、不跨会话读取),
**另加**一道参与方校验:
scope == target 且 agentName ∈ 该会话的参与方(from / to / cc) ⇒ 放行
- 不引入工作区轴(那是 cwd/沙箱那条轴,2026-09-15 明确划开)。
- 不新建表、不加迁移。
- 参与方判定与 `SessionParticipants` 同源(逐封扫 mails,同一个 `models.Address`
JSON 解析),避免两处对同一份数据给出不同答案。
- 抄送方算参与方:生产里有 2622 行非空 cc_list,只认 from/to 会把正当读者判成外人。
## fail closed
查参与方出错(DB 不可用、cc_list 解析失败)一律拒绝并带错误。
这道闸的失败模式必须是沉默的拒绝 —— 一旦「查不到就放行」,
数据库一抖就等于把漏洞重新打开,且没有任何日志。
## 判据(9 格,含改写)
改写 2 格:`AllowsOwnSession` 原来用 `uuid.New()` 造一条**不存在的**会话
就断言放行 —— 那正是漏洞的形状;`IgnoresAgentName` 断言「身份不参与判断」,
**这条断言本身就是漏洞**。两者都改成断言新事实。
新增 7 格,覆盖:声明别人会话被拒 / 抄送方放行 / 主收件方放行 / 空身份拒绝 /
判定随身份改变 / 跨会话仍拒(参与方也不行)/ fail closed。
**变异验证**(每条确认已应用后才数红格):
退回漏洞原状(跳过参与方校验) → 红 3
只认 from_agent(漏 to 与 cc) → 红 1 ★(先测时红格为 0,补了主收件方那格才抓住)
fail open(查不到就放行) → 红 1 ★(同样先红格为 0,补了 fail-closed 那格)
后两条是**补判据的过程**:`return true` 那版和「只看 from」那版都能全套通过,
说明原先的判据盯不住这两个改法。
## 对现有桥的影响(部署前实测)
按生产数据核对四个桥:「from_agent 是它、但它不是任何邮件参与方」的会话
只有 1 条,且**零邮件**(一条权限请求测试会话)—— 那类会话没有邮件可读,
判据影响为零。桥不会被误伤。
## 波及面
`AgentMayReadSession` 有 5 个消费点:read_mail / read_thread / 读会话参与者 /
forward 的源信 / AgentGetMailThread 另一分支。全部自动获得这道判据。
全量 14 包绿。
|
2026-10-02 13:27:43 +08:00 |
|
|
|
15e4fe9203
|
fix(安全): 未声明 session_id 不再放行 —— 实测任意 agent 可读全部邮件正文
## 漏洞(亲自实测,不是读码推断)
用 dsh 的密钥、不带任何 session_id,逐个 GET `/api/v1/agent/mail/{id}`:
20 封别人的信(收件方 pi / homeagent / opencode,分属
/home/program/TrueAgent 等不同工作区)⇒ **20 封全部 200,拿到完整正文**,0 拒绝。
对照(证明闸本身没坏,只有一个缺口):
带自己参与的 session_id 读别人的信 ⇒ 403 ← 闸有效
不带 session_id ⇒ 200 ← 漏洞
根因是 `AgentMayReadSession` 的一个分支:`if scope == nil { return true }`。
该函数 2026-09-15 的注释写明「首次接线前的旧语义(未声明 scope)保持放行」——
那是**迁移期妥协**,不是设计。
## 为什么「迁移期」已经结束(实测数据推翻了当初的假设)
当初假设「未接线的桥/脚本/浏览器会走这里,等接完就收口」。而
`[agent-scope]` 警告日志累计 203 次,按调用方拆开:
homeagent 125 / dsh 40 / pi 37 / opencode 1 / 其它 0
⇒ **202/203 来自四个桥自己**,集中在 `/api/v1/agent/mail/{id}`(pi 24 次)、
读会话参与者、`/mail/{id}/forward`。
不是「少数旧客户端没接线」,而是**主力客户端在裸奔**,而放行恰好把它们全漏过去。
日志抓手已完成使命:它精确指出了「谁还没带」,答案就是所有人。
## 为什么不能靠「补齐调用方」收口
要同时改四个桥(pi 的 `getMailSessionId` 有 `= () => ''` 的默认值,
忘注入就是静默空串 ⇒ 回到裸奔)。**默认放行与默认拒绝的差别就在这里:
前者的失败模式是沉默的。** 任何一处漏了 = 静默越权,且没有任何东西会红。
## 判据
`grep -rln AgentMayReadSession --include=*_test.go` ⇒ 修改前**零覆盖**。
一个决定安全边界的函数没有任何判据,这就是妥协能活到今天的原因。
新增 4 格:未声明须拒 / 声明且相等须放行 / 跨会话须拒 / 判定不随 agentName 变
(后者钉住 2026-09-15 裁定「每个 session 是独立『用户』」,防有人顺手加按 agent 的仲裁)。
两个变异都经得起:恢复放行、reason 改成 handler 不认识的值,各红一格。
reason 复用既有的 `not-your-session`:新增 reason 不同步改 handler 的 switch
就会把 403 变成 500;复用后 canReadSession 的 self 为空时,文案自然表达
「你还没声明自己在哪条会话」。
验证:13 包全绿 + `-race` 干净。
|
2026-10-02 00:34:13 +08:00 |
|
|
|
81ec77ae1f
|
fix(read)!: 会话即主体 —— 读路径不再做任何"谁有资格"的仲裁
用户(第二次、说明白了):「我要求的是不同 session 不同收件箱,而不是复杂的权限隔离……
每个 session 是相对独立的单位,他们不应该公用一个相对私有化的设施,相当于每个 session
概念上是一个独立的『用户』」
所以 AgentMayReadSession 只剩一句话:**target 必须就是 scope**(我当前所在的那条会话)。
删掉两样东西:
· 工作区比较 —— 那是 cwd/沙箱那条轴的事,混进读信就制造了 zcode 那次误判
(cross-workspace 的 403 让它推断"那五位 agent 不存在");
· **参与性仲裁** —— 那是我加的"相对私有化"设施:把 agent 当成一个跨所有会话的人,
于是它既太松(同工作区内能互翻收件箱)又太严(发起者读不到自己发起的会话)。
会话是独立单位,不需要一个更高层身份来"授权"它读自己的收件箱。
信任边界在桥:session_id 由 worker 闭包注入(模型改不了),等同"邮件客户端替它持有的
每个账号行事"。未声明 scope 的旧语义保持放行(记警告)。
判据重写为:① 我就是这条会话 → 放行;② 同工作区另一条会话 → 拒;③ 别的工作区会话 → 拒
(同一个理由 not-your-session);④ 未声明 → 迁移期放行;⑤ **显式钉住"不做参与性仲裁"**
(声明自己是哪条会话即以其行事,即便该 agent 名义上没参与过)—— 这条是模型的一部分,
免得以后被"好心"加回来;将来若要做"每个 session 自带凭据",那是加凭据而不是加回这层仲裁。
生产实测:① 200;② 403「这封信不在你当前所在的那条会话里……」;③ 200(迁移期放行)。
|
2026-09-15 10:01:24 +08:00 |
|
|
|
6b86836343
|
fix(read)!: 读信只认会话 —— 删掉「同工作区」那道闸门
用户订正:「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的
记忆是隔离的。你之前那个修法简直荒谬」
我把两条轴混在一起了:**工作区**决定 cwd/沙箱/线索可见面,**会话**决定记忆与收件箱。
原先 AgentMayReadSession 判「参与过 + 同工作区」,两头都错:
· 太松:同工作区内、我参与过的**别的会话**也放行 —— 会话之间记忆隔离,读别的会话
就是绕过隔离(用户上午的要求本就是"不同 session 不同收件箱");
· 太严:我在 A 工作区的上下文里读不到**自己刚发起**、落在 B 工作区那条会话。
zcode 撞上这条,403 文案还写着 cross-workspace ⇒ 它推断出"那五位 agent 不存在"。
新判据两句:① target 必须就是 scope(我当前那条会话);② 我参与过 target(防伪造
session_id)。scope==nil 保持迁移期放行(五家桥实测都带 session_id)。
403 文案换成「这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的
记忆是隔离的)。你当前在会话 <id>。要读它,就在它那条会话里读 —— 每封来信都会把 worker
唤醒到它自己那条会话上」。报"我在哪"安全,目标会话在哪不报。
判据:workspace_scope_test 的该用例改名并重写为五条形态(自己那条会话放行 / 同工作区
另一条会话拒 / 别的工作区会话拒 / 伪造 session_id 拒 / 未声明 scope 迁移期放行)。
生产实测:① 200 ② 403 not-your-session(新文案)③ 403(伪造)。
|
2026-09-15 09:59:21 +08:00 |
|
|
|
1b8cd43935
|
fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,
还需要我亲自去解释。」
## 根因不是漏了一个 WHERE,是隔离单位选错了
Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是
`AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里"
—— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话
也成立。
现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」
(08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11
—— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。
更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**,
所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。
## 改法:补一维,而不是逐个端点打补丁
- 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据)
② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。
- 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace ——
**不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。
- 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` /
`list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄:
跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。
- 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走,
但"还有谁没接线"必须可观测(另四家桥仍走这条路)。
- pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。
## 顺手修掉一个真 bug
联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是
"forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`,
SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。
管理员 `?all=true` 走的正是这条路。现在 $1 恒传。
## 判据(两侧都验 + 变异)
- repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 /
未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在);
建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。
- ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄,
于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。
- 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 /
SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。
- pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检;
变异 read_mail 去掉收窄 ⇒ 恰好那一条红。
(工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
|
2026-09-14 23:03:25 +08:00 |
|
|
|
a696b2a141
|
fix(handler): 回信的 to_workspace 从会话 workspace 继承 —— 修「每封邮件多一条会话」
# 现象(生产实测)
在 DSH 界面上观察到的:每处理一封邮件就多出一条独立会话。
# 根因
`to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,而它取的是
**地址里的 path 位**。Agent 之间的回信、以及人在对话页点回复,地址里通常没有
path 位 —— 平台下发的 `reply_address` 就是这个形状(`FormatAddress(replyTo, "", alias)`)。
空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在一个
不同的空目录**里;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件
就多出一条未分组会话」,而模型在那个空目录里什么项目文件也看不到。
实测取证:
- 线上 5 个兜底目录 `~/.dsh/mail-sessions/mail-*` **全部是空的**(0 条目)
- 全天 journalctl 里**没有任何**相关告警(代码用的是 `ctx.logger.warn`,
而同一文件别处明确写着 DSH 的 logger 不进 journalctl)→ 完全静默
- 走兜底的那条会话(8e982e96)里,`dsh → opencode` 那封 `to_workspace` 有值,
而 `opencode → dsh` 的回信 **to_workspace 全为空** —— 而该会话自身的
`sessions.workspace` 一直是有值的
# 修法
会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来就是
回给**那条会话**的,而那条会话知道自己属于哪个项目。规则抽成纯函数
`resolveToWorkspace(addrPath, sessionWorkspace, toIsHuman)`:
1. 地址里写了 path → 照用(人的明确意图优先)
2. 没写且收件方是**人** → 保持空(人没有工作目录;填了前端会拼出
`gui-lab@/path.别名` 这种错地址,ToHuman 字段就是为此加的)
3. 没写且收件方是 Agent → 用会话的 workspace
**改的是 `to`,不是只改建库那一行**:同一个值还进投递载荷(`to_workspace` /
`self_address`)。改一处另一处不改,会出现「API 读到的与插件推到的不是同一个
目录」——那正是本项目一直在治的静默不一致。
`notify/mail.go` 只加了一段注释说明 reply_address 的 path 位为何**刻意留空**
(它的语义是「**发件人**该在哪儿干活」),免得后人以为那是漏填。
# 测试
- `handler/toworkspace_test.go`:6 条规则用例 + 1 条**反向对照**
(固定其他输入只翻转 toIsHuman,要求结果必须不同 —— 防止该参数被忽略后
「给人也填 path」静默回归)
- `repo/session_workspace_test.go`:锁住**列名与真实 schema**。这个查询读不到时
按设计返回空串,与「这条会话没有工作目录」无法区分 → 列名写错的功能表现是
「看起来还在跑,只是工作目录永远继承不到」
|
2026-09-12 11:19:19 +08:00 |
|