Files
MailUI4Agents/server/internal/handler/permission_request_test.go
JianFeeeee 4e32dd3145 fix(permission): Agent 不能把审批指派给与任务无关的人
# 漏洞

`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 自动项全绿)
2026-09-11 23:22:53 +08:00

177 lines
5.9 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 handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// RequestPermission 的决策人解析必须在 HTTP handler 层有覆盖。
//
// 过去 `to` 是 Agent 可自由填写的用户名,只要对应人类账号存在就直接采用。
// 这允许 Agent 把危险操作的审批丢给任意一个与任务无关的人repo 层的参与方
// helper 即使正确handler 漏调一次仍会把漏洞带回来。
func TestRequestPermissionRestrictsExplicitDeciderToThread(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
mustPermissionUser(t, "owner")
mustPermissionUser(t, "reviewer")
mustPermissionUser(t, "outsider")
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
t.Fatalf("注册 pi: %v", err)
}
ownerID := permissionUserID(t, "owner")
sid, err := repo.CreateSession(ctx, nil, "pi", "授权路由", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if err := repo.SetSessionOwner(ctx, sid, ownerID); err != nil {
t.Fatalf("设置 owner: %v", err)
}
if _, err := repo.CreateMail(ctx, sid, nil, "reviewer", "", "pi", "/tmp",
"任务", "请执行", nil); err != nil {
t.Fatalf("建线索邮件: %v", err)
}
t.Run("无关人类被忽略并回落 owner", func(t *testing.T) {
resp := requestPermission(t, sid, "outsider", "是否允许执行 bash")
if resp.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
}
var body map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应: %v", err)
}
if got := body["decider"]; got != "owner" {
t.Fatalf("无关人类不应收到审批decider=%v期望 owner", got)
}
assertPermissionRecipient(t, body["mail_id"].(string), "owner")
})
t.Run("线索上的人类可被显式指定", func(t *testing.T) {
resp := requestPermission(t, sid, "reviewer", "是否允许写文件?")
if resp.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
}
var body map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应: %v", err)
}
if got := body["decider"]; got != "reviewer" {
t.Fatalf("线索上的人类应保持显式优先级decider=%v", got)
}
assertPermissionRecipient(t, body["mail_id"].(string), "reviewer")
})
t.Run("线索上的 Agent 仍不能成为决策人", func(t *testing.T) {
resp := requestPermission(t, sid, "pi", "是否允许编辑?")
if resp.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
}
var body map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应: %v", err)
}
if got := body["decider"]; got != "owner" {
t.Fatalf("Agent 不能在 WebUI 决策,应回落 owner实际 %v", got)
}
})
}
func TestRequestPermissionRejectsAllAgentThread(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
for _, name := range []string{"pi", "dsh"} {
if err := repo.CreateOrUpdateAgent(ctx, name, "secret", "test", nil); err != nil {
t.Fatalf("注册 %s: %v", name, err)
}
}
sid, err := repo.CreateSession(ctx, nil, "pi", "纯 Agent 链", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if _, err := repo.CreateMail(ctx, sid, nil, "dsh", "", "pi", "/tmp",
"任务", "请执行", nil); err != nil {
t.Fatalf("建线索邮件: %v", err)
}
resp := requestPermission(t, sid, "human", "是否允许执行 bash")
if resp.Code != http.StatusConflict {
t.Fatalf("纯 Agent 链必须 fail closedHTTP %d: %s", resp.Code, resp.Body.String())
}
if bytes.Contains(resp.Body.Bytes(), []byte(`"decider":"admin"`)) {
t.Fatalf("不得退回无关管理员: %s", resp.Body.String())
}
}
func setupPermissionHandlerDB(t *testing.T) {
t.Helper()
db.Close()
path := filepath.Join(t.TempDir(), "permission-handler.db")
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
t.Cleanup(db.Close)
}
func mustPermissionUser(t *testing.T, username string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (user_id, username, display_name, password_hash, role)
VALUES ($1, $2, $3, 'x', 'user')`, uuid.New(), username, username); err != nil {
t.Fatalf("建用户 %s: %v", username, err)
}
}
func permissionUserID(t *testing.T, username string) uuid.UUID {
t.Helper()
var id uuid.UUID
if err := db.DB.QueryRowContext(context.Background(),
`SELECT user_id FROM users WHERE username = $1`, username).Scan(&id); err != nil {
t.Fatalf("查用户 %s: %v", username, err)
}
return id
}
func requestPermission(t *testing.T, sid uuid.UUID, to, question string) *httptest.ResponseRecorder {
t.Helper()
payload, _ := json.Marshal(map[string]any{
"session_id": sid.String(),
"to": to,
"question": question,
"options": []string{"同意", "拒绝"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/permission/request", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), middleware.AgentNameKey, "pi")
resp := httptest.NewRecorder()
RequestPermission(resp, req.WithContext(ctx))
return resp
}
func assertPermissionRecipient(t *testing.T, mailID, want string) {
t.Helper()
var got string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT to_name FROM mails WHERE mail_id = $1`, mailID).Scan(&got); err != nil {
t.Fatalf("查权限邮件 %s: %v", mailID, err)
}
if got != want {
t.Fatalf("权限邮件收件人=%q期望 %q", got, want)
}
}