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 自动项全绿)
This commit is contained in:
2026-09-11 23:22:53 +08:00
parent f11415834a
commit 4e32dd3145
4 changed files with 344 additions and 1 deletions

View File

@ -3,6 +3,7 @@ package handler
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
@ -166,7 +167,25 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
// 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的:
// 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾,
// 先执行的那条把后写的那条变成了死代码。
decider := req.To
decider := strings.TrimSpace(req.To)
// 显式指定的决策人只在**确属这条线索**时才采信。
//
// `to` 由 Agent 自由填写,原来只要它是合法的人类用户名就直接采用,于是
// 一个 Agent 可以把「是否允许执行 bash」丢给任意一个与这条任务无关的人。
// 这与上面 409 分支的理由直接冲突:既然「管理员对这条 Agent 链一无所知」
// 是拒绝转给管理员的依据,那 Agent 自己点名一个无关的人同样不成立。
// 权限应当追溯到最初分配任务的人,也就是这条线索上的人。
//
// 这里**丢弃提示而不是报错**:它只是一个偏好,下面常规解析仍会给出一个
// 合法人类owner 或线索上最近的人),否则明确 409 —— 无论哪条,都不会
// 把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个任务,
// 而它并没有做错什么。丢掉是静默的,所以这里必须留下日志。
if decider != "" && decider != "human" && !repo.IsHumanOnSessionThread(r.Context(), sessionID, decider) {
log.Printf("[permission] 忽略不属于本线索的决策人 %q会话 %s由 %s 指定)—— 改走常规解析",
decider, sessionID, agentName)
decider = ""
}
if decider == "" || decider == "human" {
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
if err == nil && owner != "" {

View File

@ -0,0 +1,176 @@
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)
}
}

View File

@ -133,3 +133,45 @@ func sortedKeys(m map[string]bool) []string {
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
}

View File

@ -0,0 +1,106 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// 权限询问的决策人白名单。
//
// `/permission/request` 的 `to` 字段由 Agent 自由填写,原来只要它是合法的人类
// 用户名就直接采用 —— 于是一个 Agent 可以把「是否允许执行 bash」丢给任意一个
// 与这条任务无关的人。这与 handler 里那段 409 的理由直接冲突:既然「管理员对
// 这条 Agent 链的上下文一无所知」是拒绝转给管理员的依据,那 Agent 自己点名一个
// 无关的人同样不成立。权限应当追溯到最初分配任务的人,也就是这条线索上的人。
func mustHuman(t *testing.T, username string) uuid.UUID {
t.Helper()
id := uuid.New()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (user_id, username, display_name, password_hash, role)
VALUES ($1, $2, $3, 'x', 'user')`,
id, username, username); err != nil {
t.Fatalf("建用户 %s: %v", username, err)
}
return id
}
func TestIsHumanOnSessionThread(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "pi", "权限决策人", "/home/program/agentmail")
ownerID := mustHuman(t, "laoban")
mustHuman(t, "dispatcher")
mustHuman(t, "reviewer")
mustHuman(t, "passerby") // 存在,但与这条线索无关
// 会话里有人类 dispatcher / reviewer 往来owner 由平台指派、从未收发过邮件。
mustMail(t, sid, "dispatcher", "", "reviewer", "/home/program/agentmail", nil)
if err := SetSessionOwner(ctx, sid, ownerID); err != nil {
t.Fatalf("指派 owner: %v", err)
}
cases := []struct {
name string
who string
want bool
why string
}{
{"线索上发过信的人类", "dispatcher", true,
"发件人就是最初分配任务的人,权限第一该问他"},
{"线索上收过信的人类", "reviewer", true,
"人类收件人也是参与者"},
{"线索上的 Agent 不是决策人", "pi", false,
"出现在邮件里只是必要条件;只有人类用户才能在 WebUI 决策"},
{"会话 owner没发过邮件", "laoban", true,
"owner 与参与方是两处来源,缺一不可:会话由 Agent 建立、owner 由平台指派时,只看邮件会把合法 owner 判成外人"},
{"存在但与线索无关的人", "passerby", false,
"这就是要堵的那个洞Agent 不能把审批丢给与任务无关的人"},
{"根本不存在的人", "nobody", false,
"白名单从正面判定,不存在的名字自然不通过"},
{"空串", "", false,
"空串的语义是「没指定」,走常规解析,不该被当成线索上的人"},
}
for _, c := range cases {
if got := IsHumanOnSessionThread(ctx, sid, c.who); got != c.want {
t.Errorf("%sIsHumanOnSessionThread(%q) = %v应为 %v —— %s",
c.name, c.who, got, c.want, c.why)
}
}
}
// 抄送方也算线索上的人:一次抄送就把人带进了这条线索。
func TestIsHumanOnSessionThreadCountsCC(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "pi", "抄送也算", "/home/program/agentmail")
mustHuman(t, "dispatcher")
mustHuman(t, "observer")
mustMail(t, sid, "dispatcher", "", "pi", "/home/program/agentmail", nil)
// 抄送方由第二封信带入 —— 一次抄送就把人带进了这条线索。
cc := []models.Address{{Name: "observer", Path: "/home", Session: "new", Raw: "observer@/home.new"}}
if _, err := CreateMail(ctx, sid, nil, "dispatcher", "/home/program/agentmail",
"pi", "/home/program/agentmail", "带抄送", "正文", cc); err != nil {
t.Fatalf("建带抄送的邮件: %v", err)
}
if !IsHumanOnSessionThread(ctx, sid, "observer") {
t.Error("人类抄送方应算作线索上的人:一次抄送就把他带进了这条线索")
}
}
// 会话不存在时不得 panic且必须返回 falsefail closed
func TestIsHumanOnSessionThreadUnknownSession(t *testing.T) {
setupTestDB(t)
if IsHumanOnSessionThread(context.Background(), uuid.New(), "admin") {
t.Error("会话不存在时应返回 falsefail closed不能因为查不到就往放行方向倒")
}
}