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:
@ -110,9 +110,32 @@ type SessionCandidate struct {
|
||||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||||
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
|
||||
return suggestSessionCandidates(ctx, forUser, peerName, path, nil)
|
||||
}
|
||||
|
||||
// SuggestSessionCandidatesInWorkspace 额外要求候选会话属于 `workspace`。
|
||||
//
|
||||
// Agent 侧声明了「我在哪条会话」时走这条:别人工作区的会话别名与标题
|
||||
// 对调用方就是不该看的信息(与 repo.AgentMayReadSession 同一条命)。
|
||||
// 只收窄会话候选:`paths`(对方用过哪些目录)与 `new` 都保留,
|
||||
// 因为跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索"。
|
||||
func SuggestSessionCandidatesInWorkspace(ctx context.Context, forUser, peerName, path, workspace string) ([]SessionCandidate, error) {
|
||||
return suggestSessionCandidates(ctx, forUser, peerName, path, &workspace)
|
||||
}
|
||||
|
||||
func suggestSessionCandidates(ctx context.Context, forUser, peerName, path string, onlyWorkspace *string) ([]SessionCandidate, error) {
|
||||
out := []SessionCandidate{}
|
||||
seen := map[string]int{} // alias -> out 下标
|
||||
|
||||
// 工作区收窄只在本侧线索那条查询里能加:`sessions.workspace` 才是会话的工作区,
|
||||
// 镜像表(来源 2)自己那份 workspace 列另算。
|
||||
wsClause := ""
|
||||
wsArgs := []any{}
|
||||
if onlyWorkspace != nil {
|
||||
wsClause = ` AND COALESCE(s.workspace, '') = $4`
|
||||
wsArgs = append(wsArgs, *onlyWorkspace)
|
||||
}
|
||||
|
||||
// ---- 来源 1:本侧邮件线索 ----
|
||||
//
|
||||
// sessions.workspace 是权威来源。它是新加的列,历史会话为空串,
|
||||
@ -146,9 +169,9 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $3 OR mm.to_name = $3
|
||||
OR `+db.CCHas("mm.cc_list", 3)+`)
|
||||
))
|
||||
))`+wsClause+`
|
||||
ORDER BY s.updated_at DESC
|
||||
`, peerName, path, forUser)
|
||||
`, append([]any{peerName, path, forUser}, wsArgs...)...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@ -176,16 +199,26 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
}
|
||||
|
||||
// ---- 来源 2:平台会话镜像 ----
|
||||
//
|
||||
// 镜像表有自己的 workspace 列,所以这里另写一个参数位($3),
|
||||
// 不与来源 1 复用 $4 —— 两条查询的参数个数不同,硬凑一个编号只会让
|
||||
// 下一次改动踩到"占了位子却没人绑"的坑。
|
||||
mirrorWS := ""
|
||||
mirrorArgs := []any{peerName, path}
|
||||
if onlyWorkspace != nil {
|
||||
mirrorWS = ` AND COALESCE(workspace, '') = $3`
|
||||
mirrorArgs = append(mirrorArgs, *onlyWorkspace)
|
||||
}
|
||||
prows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT slug, title, platform_id
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1
|
||||
AND slug <> ''
|
||||
AND ($2 = '' OR workspace = $2)
|
||||
AND ($2 = '' OR workspace = $2)`+mirrorWS+`
|
||||
-- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
|
||||
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
`, peerName, path)
|
||||
`, mirrorArgs...)
|
||||
if err != nil {
|
||||
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
|
||||
return out, nil
|
||||
|
||||
Reference in New Issue
Block a user