Files
MailUI4Agents/server/internal/repo/session_workspace.go
JianFeeeee 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

86 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// SessionWorkspaceOf 返回**这条会话**的工作目录(`sessions.workspace`)。
//
// # 为什么需要它
//
// 邮件的 `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,
// 但它取的是**地址里的 path 位**。而 Agent 之间的回信、以及人在对话页点回复时,
// 地址里通常没有 path 位 —— 平台自己下发的 `reply_address` 就是这个形状。
//
// 空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在
// 一个不同的空目录里**;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理
// 一封邮件就多出一条未分组会话」,而模型在空目录里什么项目文件也看不到。
//
// 会话的 workspace 才是权威来源(见 `models.SessionWorkspace` 的注释):
// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
//
// 读不到时返回空串而不是报错:投递路径不能因为一次查询失败就丢掉工作目录信息,
// 但也不能凭空编一个 —— 空串的语义就是「不知道」,由调用方决定怎么退化。
func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string {
var ws string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(workspace, '') FROM sessions WHERE session_id = $1`, id).Scan(&ws)
if err != nil {
return ""
}
return ws
}
// AgentMayReadSession 判「以 `scope` 为当前会话的 Agent 能不能读 `target`」。
//
// # 两道独立闸门,缺一不可
//
// ① 该 Agent 参与过目标会话(AgentCanAccessSession:出现在 from/to/cc 里)
// ② 两条会话的 workspace 相同(本函数新增的那道)
//
// # 为什么必须补 ②:一个 Agent 同时服务所有工作区
//
// Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
// 于是 **agent `pi` 既"参与过" agentmail 的会话、也"参与过" TrueAgent 的会话** ——
// 只靠 ① 时,一个在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索。
// 2026-09-14 用户报的正是这个:`mail_reads` 里能看到同一瞬间读了跨三个工作区的
// 会话(agentmail / TrueAgent / webui4frpc)。当时的读者身份**只有 reader_name,
// 没有"读的人在哪"** —— 也就是说这类越界读在数据上与正常读无法区分。
//
// # 判据取严,且只有一个定义
//
// workspace 按字面比较(空字符串 = 空字符串),不相等即拒。刻意**不做**
// 「workspace 为空时回头从 mails 反推」那套兜底 —— 兜底会让"这条会话到底属于哪个
// 工作区"有两个来源,而隔离判据最不需要的就是第二个来源。(2026-09-14 实测:
// 线上 workspace 为空的 6 条会话里,没有一条的邮件带 to_workspace,所以这条
// 兜底在当前数据上不生效;真出现时应当**拒读并留下一条日志**,而不是悄悄放行。)
//
// `scope == nil` 表示调用方**没有声明**自己在哪条会话(尚未接线的桥、脚本、浏览器)。
// 那是旧语义:放行。它存在的唯一理由是让迁移可以分步走,调用点会记一条警告,
// 收尾时按日志把还没接线的调用方找全。
//
// 返回的 reason 只用于日志与报错文案,不参与判定:
//
// "" 允许
// "not-participant" 该 Agent 没参与过这条会话
// "cross-workspace" 参与了,但两条会话不在同一工作区
func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID, target uuid.UUID) (bool, string, error) {
participates, err := AgentCanAccessSession(ctx, agentName, target)
if err != nil {
return false, "", err
}
if !participates {
return false, "not-participant", nil
}
if scope == nil {
return true, "", nil
}
if SessionWorkspaceOf(ctx, *scope) != SessionWorkspaceOf(ctx, target) {
return false, "cross-workspace", nil
}
return true, "", nil
}