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 个文件。)
This commit is contained in:
@ -1121,12 +1121,34 @@ type Contact struct {
|
||||
// forUser 非空时只列该用户参与的会话(owner / 收发 / 抄送);空表示不限(管理员全局视图)。
|
||||
// archived=false 只列活跃会话,true 只列归档会话。
|
||||
func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Contact, error) {
|
||||
return listContacts(ctx, forUser, nil, archived)
|
||||
}
|
||||
|
||||
// ListContactsInWorkspace 只在**同一个工作区**里列会话 —— Agent 侧读联系人的默认口径。
|
||||
//
|
||||
// 为什么 Agent 侧要收窄:一个 Agent 同时服务所有工作区(见 AgentMayReadSession 的注释),
|
||||
// 不收窄的话,在 TrueAgent 里干活的 worker 调 list_contacts 会拿到 agentmail 工作区的
|
||||
// 会话标题、别名与未读计数 —— 而它连看都不该看到(用户 2026-09-14 报的那类越界)。
|
||||
//
|
||||
// 人类侧刻意**不走**这条:人的可见性由 `UserCanAccessSession` 与 owner 决定,
|
||||
// 与工作区不是一回事(一个人类可能同时管好几个项目的会话)。
|
||||
func ListContactsInWorkspace(ctx context.Context, agentName, workspace string, archived bool) ([]Contact, error) {
|
||||
return listContacts(ctx, agentName, &workspace, archived)
|
||||
}
|
||||
|
||||
func listContacts(ctx context.Context, forUser string, onlyWorkspace *string, archived bool) ([]Contact, error) {
|
||||
op := "<>"
|
||||
if archived {
|
||||
op = "="
|
||||
}
|
||||
// $1 恒为 forUser,即使它是空串。
|
||||
//
|
||||
// 未读计数那个子查询里 `r.reader_name = $1` **一直在引用 $1**,而原先的写法是
|
||||
// 「forUser 为空就不传参」—— 那样 $1 就悬空了:Postgres 直接报 no parameter $1,
|
||||
// SQLite 则静默把 `= $1` 当 `= NULL` 比(次次不成立,未读计数退化成“全部未归档”)。
|
||||
// 管理员 `?all=true` 正是走的那条悬空路径(scope="")。
|
||||
scope := ""
|
||||
args := []any{}
|
||||
args := []any{forUser}
|
||||
if forUser != "" {
|
||||
scope = ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
|
||||
OR EXISTS (
|
||||
@ -1135,7 +1157,10 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
|
||||
AND (mm.from_name = $1 OR mm.to_name = $1
|
||||
OR ` + db.CCHas("mm.cc_list", 1) + `)
|
||||
))`
|
||||
args = append(args, forUser)
|
||||
}
|
||||
if onlyWorkspace != nil {
|
||||
args = append(args, *onlyWorkspace)
|
||||
scope += fmt.Sprintf(` AND COALESCE(s.workspace, '') = $%d`, len(args))
|
||||
}
|
||||
// 取会话里最早那封邮件作为联系人身份。
|
||||
// PG 用 LATERAL 子查询;SQLite 无 LATERAL,改用关联子查询逐列取值
|
||||
|
||||
Reference in New Issue
Block a user