# 漏洞
`POST /permission/request` 的 `to` 字段由 Agent 自由填写,服务端只检查
「这个名字是不是一个合法的人类用户」:
decider := req.To
if isHuman, _ := repo.IsHumanUser(ctx, decider); !isHuman { …回落… }
于是任何 Agent 都能把「是否允许执行 bash」这类危险操作的审批丢给**任意一个
与这条任务无关的人**(例如管理员)。被点名的人看到一封没有上下文的待办,
只能凭猜点头或拒绝。
这跟同一份代码里的另一段注释直接冲突。那段在论证为什么不把权限转给管理员:
管理员对这条 Agent 链的上下文一无所知,既不知道这个 bash 命令在做什么,
也不知道拒绝后 Agent 该怎么绕过去。
这个理由同样适用于「Agent 自己点名一个无关的人」—— 而且更弱:至少管理员还能
查日志,一个随机被点名的用户连从哪查都不知道。两处都指向同一条规则:
**权限应当追溯到最初分配任务的人**,也就是这条线索上的人。
这是静态审计发现的四项之一。当时三桥实测都不传 `to`,所以是潜在面而非活跃
漏洞 —— 但 `to` 是公开的 Agent API 字段,第三方插件照着文档填就会踩上。
# 修法
新增 `repo.IsHumanOnSessionThread(ctx, sessionID, name)`:人类身份 **且**
(会话 owner 或在这条会话的某封邮件里出现过)。
两个来源缺一不可,各有实测场景:
- **只要参与方**会漏掉「会话由 Agent 建立、owner 由平台指派」的会话 ——
那种 owner 可能一封邮件都没收发过,只看邮件会把合法 owner 判成外人,
于是每次审批都回落到线索上随便一个人类。
- **只要 owner** 会漏掉「人在别人的会话里被抄送进来说了话」这种正常协作。
采信与否的处置是**丢弃提示而不是报错**:`to` 只是一个偏好,丢弃后常规解析仍会
给出一个合法人类(owner 或线索上最近的人),实在没有就是既有的 409 —— 无论哪条
分支,都不会把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个
任务,而它并没有做错什么。因为丢弃是静默的,所以**必须留下日志**:
[permission] 忽略不属于本线索的决策人 "jianf"(会话 …, 由 pi 指定)—— 改走常规解析
# 测试
补了这条路径此前**完全缺失**的两层覆盖(审计发现:决策路径
RequestPermission/DecidePermission/ListPendingPermissions 都没有测试):
- `repo/threadhuman_test.go`:白名单的六种输入(线索上发信/收信的人类、没发过
邮件的 owner、线索外的存在用户、不存在的名字、空串、抄送方),每条都写清
为什么期望这个结果。
- `handler/permission_request_test.go`:**真实 HTTP 层**跑 `RequestPermission`,
断言响应里的 decider 与库里那封权限邮件的 to_name。repo 层 helper 正确但
handler 漏调一次,漏洞就会回来,所以必须有端到端这一层。含纯 Agent 链的
fail-closed 断言(不得退回管理员)。
# 验证
- `go test ./... -count=1` 全绿;`go vet` 干净;`gofmt` 差异行数与改动前完全
相同(8 行,既有的一处空行)—— 即本次改动零新增格式问题
- 真机(workspace 档会话,owner=gui-lab,线索参与者 gui-lab+pi):
- `to=jianf`(线索外人类)→ decider=gui-lab,库中 to_name=gui-lab,
日志有忽略记录
- `to=gui-lab`(线索内)→ 采纳
- `to=pi`(Agent)→ 忽略,回落 owner
- 已部署(redeploy-gateway.sh 自动项全绿)
178 lines
5.9 KiB
Go
178 lines
5.9 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"sort"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Participant 是一条会话里的一个参与方。
|
||
//
|
||
// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、
|
||
// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里
|
||
// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。
|
||
type Participant struct {
|
||
Name string `json:"name"`
|
||
Path string `json:"path"`
|
||
// Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。
|
||
// 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让
|
||
// 「谁是这件事的负责人」这个判断出错。
|
||
Roles []string `json:"roles"`
|
||
// MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」——
|
||
// 参与方列表里 from 计数为 0 的那个就是还没开口的人。
|
||
MailCount int `json:"mail_count"`
|
||
}
|
||
|
||
// SessionParticipants 列出会话的全部参与方及各自的地址素材。
|
||
//
|
||
// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时
|
||
// 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有
|
||
// from_agent 一个名字,回答不了「这条线索上现在有谁」。
|
||
//
|
||
// 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前,
|
||
// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。
|
||
func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
SELECT from_name, COALESCE(from_workspace,''),
|
||
to_name, COALESCE(to_workspace,''),
|
||
cc_list
|
||
FROM mails
|
||
WHERE session_id = $1
|
||
ORDER BY created_at ASC, mail_id ASC
|
||
`, sessionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
type acc struct {
|
||
p Participant
|
||
roles map[string]bool
|
||
order int
|
||
}
|
||
seen := map[string]*acc{}
|
||
next := 0
|
||
|
||
// note 记录一次「某人以某身份出现」。
|
||
//
|
||
// path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同
|
||
// path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个,
|
||
// 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。
|
||
note := func(name, path, role string, isSender bool) {
|
||
if name == "" {
|
||
return
|
||
}
|
||
a, ok := seen[name]
|
||
if !ok {
|
||
a = &acc{
|
||
p: Participant{Name: name, Path: path},
|
||
roles: map[string]bool{},
|
||
order: next,
|
||
}
|
||
next++
|
||
seen[name] = a
|
||
}
|
||
if a.p.Path == "" && path != "" {
|
||
a.p.Path = path
|
||
}
|
||
a.roles[role] = true
|
||
if isSender {
|
||
a.p.MailCount++
|
||
}
|
||
}
|
||
|
||
for rows.Next() {
|
||
var fromName, fromWS, toName, toWS string
|
||
var ccRaw []byte
|
||
if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是
|
||
// Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到
|
||
// 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||
note(fromName, "", "from", true)
|
||
note(toName, toWS, "to", false)
|
||
|
||
if len(ccRaw) > 0 {
|
||
var cc []models.Address
|
||
if json.Unmarshal(ccRaw, &cc) == nil {
|
||
for _, c := range cc {
|
||
note(c.Name, c.Path, "cc", false)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
out := make([]Participant, 0, len(seen))
|
||
for _, a := range seen {
|
||
a.p.Roles = sortedKeys(a.roles)
|
||
out = append(out, a.p)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
return seen[out[i].Name].order < seen[out[j].Name].order
|
||
})
|
||
return out, nil
|
||
}
|
||
|
||
// sortedKeys 给出稳定顺序的角色列表。
|
||
// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同,
|
||
// 插件侧做 diff 或缓存时会误判为「参与方变了」。
|
||
func sortedKeys(m map[string]bool) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// IsHumanOnSessionThread 判断某个用户名是否确属这条会话的线索 ——
|
||
// 会话 owner,或在会话某封邮件里出现过(发件人 / 收件人 / 抄送)。
|
||
//
|
||
// 用途:权限询问的决策人白名单。
|
||
//
|
||
// # 为什么需要它
|
||
//
|
||
// `/permission/request` 的 `to` 字段由 Agent 自由填写,原来只要它是合法的人类
|
||
// 用户名就直接采用。于是一个 Agent 可以把「是否允许执行 bash」这类审批丢给任意
|
||
// 一个与这条任务无关的人(例如管理员)。而上游刚用「管理员对这条 Agent 链的
|
||
// 上下文一无所知 —— 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该
|
||
// 怎么绕过去」的理由删掉了「退回第一个管理员」的兜底;同一个理由也适用于
|
||
// 「Agent 自己点名一个无关的人」。
|
||
//
|
||
// 语义上,权限应当追溯到**最初分配任务的人**,也就是这条线索上的人。
|
||
//
|
||
// 注意 owner 与 participations 是两处来源、缺一不可:owner 可能从未在这条会话里
|
||
// 收发过任何邮件(会话由 Agent 建立、owner 由平台指派),那种情况下只看邮件会
|
||
// 把合法 owner 判成外人。
|
||
func IsHumanOnSessionThread(ctx context.Context, sessionID uuid.UUID, name string) bool {
|
||
if name == "" {
|
||
return false
|
||
}
|
||
isHuman, err := IsHumanUser(ctx, name)
|
||
if err != nil || !isHuman {
|
||
return false
|
||
}
|
||
if owner, err := SessionOwnerUsername(ctx, sessionID); err == nil && owner == name {
|
||
return true
|
||
}
|
||
parts, err := SessionParticipants(ctx, sessionID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, p := range parts {
|
||
if p.Name == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|