Files
MailUI4Agents/server/internal/handler/permission.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

463 lines
19 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 (
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Permission ----------
type permissionRequestRequest struct {
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
SessionID *string `json:"session_id"`
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
To string `json:"to"`
// RelayKey 是上游那条权限询问的稳定 idopencode 的 permission.id
//
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
// 这里要的只是**幂等**permission.updated 事件会重复触发,插件也会重连重放,
// 没有幂等键就会给同一次询问生成好几封邮件。
RelayKey string `json:"relay_key"`
// Kind 区分待办类型:"permission"(危险工具审批,默认)或 "question"
// Agent 主动询问)。主动询问不套权限档位判定 —— plan/full 档也可能需要
// 补充信息,审批档不能拦它。
Kind string `json:"kind"`
// MultiSelect 仅 question 使用ask_user_question 的多选语义。
MultiSelect bool `json:"multi_select"`
}
type permissionDecideRequest struct {
MailID string `json:"mail_id"`
Decision string `json:"decision"`
Note string `json:"note"`
}
// POST /api/v1/permission/request
func RequestPermission(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req permissionRequestRequest
if !DecodeBody(w, r, &req) {
return
}
if req.Question == "" {
Error(w, http.StatusBadRequest, "Missing question")
return
}
// 校验请求类型。空串按 permission 处理(历史客户端不传也不会被拒)。
kind := strings.TrimSpace(req.Kind)
if kind == "" {
kind = "permission"
}
if kind != "permission" && kind != "question" {
Error(w, http.StatusBadRequest, `kind 只能是 ""、"permission" 或 "question"`)
return
}
options := req.Options
if len(options) == 0 && kind != "question" {
// 审批型询问必须给两个可点选项,否则人在界面上无以为答。
//
// 主动询问question不同它可能根本没有预设选项 ——
// 那是「请把你的名字告诉我」「请把报错贴给我」这类自由文本问题。
// 给它们塞「同意/拒绝」会让人只能选一个毫无意义的答案,
// 而模型拿到的 selected 里也会是这种噪音。
options = []string{"同意", "拒绝"}
}
// 幂等:同一条上游询问只生成一封邮件。
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
relayKey := strings.TrimSpace(req.RelayKey)
if relayKey != "" {
if len(relayKey) > 160 {
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
return
}
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
if errors.Is(err, repo.ErrRelayDuplicate) {
JSON(w, http.StatusOK, map[string]any{
"status": "duplicate_relay",
"relay_key": relayKey,
"detail": "该权限询问已转发过,本次调用未产生新邮件",
})
return
}
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
}
// 确定 session
var sessionID uuid.UUID
if req.SessionID != nil && *req.SessionID != "" {
id, err := uuid.Parse(*req.SessionID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid session_id")
return
}
sessionID = id
repo.TouchSession(r.Context(), sessionID)
} else {
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
id, err := repo.CreateSession(r.Context(), nil, agentName, mailSubjectFor(kind, req.Question), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create session")
return
}
sessionID = id
}
// 权限档位决定审批型询问该不该存在。**主动询问question不受此约束**
// 无论 plan/full模型都可能需要向人补充信息拦住就是阻塞整个任务。
//
// 审批型只有 workspace 档需要人:
// - plan 档 → 409。该档的语义就是「这轮不动手」没什么可问人的
// 模型该做的是把方案写在回信里。
// - full 档 → 409。已经声明全权再问一遍只是噪音插件本不该发这封信
// 发了说明它没按档位翻译,报错比静默接受好。
//
// 这也是为什么下面不再有「退回第一个管理员」的兜底:
// 既然只有一档需要人,那一档里找不到人就是 409没有中间形态。
mode := repo.SessionPermissionMode(r.Context(), sessionID)
needHuman := kind != "question" && !models.ModeNeedsHuman(mode)
if needHuman {
if relayKey != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
detail := "本会话的权限档位是 " + mode + ",不产生权限询问。"
suggestion := ""
if mode == models.ModePlan {
suggestion = "plan 档只允许读与查。请不要尝试写入或执行命令," +
"把方案、需要人工执行的步骤写在回信里。如需动手,请请发件人把档位改成 workspace。"
} else {
suggestion = "full 档下工具调用无需审批,插件不应该转发权限询问。" +
"这通常意味着插件没按会话档位配置平台的审批策略。"
}
JSON(w, http.StatusConflict, map[string]interface{}{
"error": "本会话不接受权限询问(档位 " + mode + "",
"detail": detail,
"suggestion": suggestion,
"permission_mode": mode,
})
return
}
// 决策人:显式指定优先,否则取会话 owner再否则沿线索找最近的人类。
//
// **不再退回第一个管理员**。那段兜底让下面的 409 分支永远不可达:
// decider 空 → 填上管理员 → IsHumanUser 通过 → NearestHumanInThread 根本不会被调用。
// 实测pi 给自己新开会话派活跑 bash权限邮件 to_name=jianf而那条链上
// 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的:
// 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾,
// 先执行的那条把后写的那条变成了死代码。
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 != "" {
decider = owner
}
}
// 关键防线decider 必须是人类用户。
//
// Agent 无法通过 Web UI 决策权限 —— SendToUser 投递到不存在的用户通道,
// 而桥的 await Promise 永不 resolve会话永久阻塞。这在 Agent 给自己发信时
// 必然发生pi 分配任务给自己的另一个会话 → 该会话触发权限询问 → 邮件发给 pi
// → pi 不是人类用户 → 整条会话卡死。
//
// 修复:沿会话树上溯找最近的人类节点 —— 权限应追溯到最初分配任务的人。
if isHuman, _ := repo.IsHumanUser(r.Context(), decider); !isHuman {
human, err := repo.NearestHumanInThread(r.Context(), sessionID, decider)
if err == nil && human != "" {
decider = human
} else {
// 整条任务链上没有人类Agent → Agent → Agent中间没有任何人介入。
//
// 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底,
// 把 decider 填成 adminIsHumanUser 于是通过,这里根本不会被调用。
// 实测pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
// 那段兜底已删(参见上面的档位判定)。
//
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
//
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:
// 换用不需要权限的方式subprocess、文件操作等或在邮件里说明情况让上游转给人类。
if relayKey != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
JSON(w, http.StatusConflict, map[string]interface{}{
"error": "权限询问无法送达:该任务链上没有人类用户",
"detail": "整条任务都是 Agent 之间的邮件往来,没有人类参与决策。请换用不需要权限的方式完成此操作,或在回复中说明情况让上游转达给人类。",
"suggestion": "考虑用 subprocess/file 工具替代需要权限的工具,或通过邮件向上游请求人类协助。",
"decider_was": decider,
})
return
}
}
body := req.Context
if body == "" {
body = req.Question
}
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options, kind, req.MultiSelect)
if err != nil {
// 归还幂等键,否则这次询问永远转不出来了
if relayKey != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
return
}
if relayKey != "" {
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
}
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context, kind, req.MultiSelect); err != nil {
Error(w, http.StatusInternalServerError, "Failed to create permission request")
return
}
// 只推给该决策人。
//
// 这一处不走 notify.Recipients那个函数推给「三维地址解析出的参与方」
// 而权限询问的投递对象是逐会话树找出来的人类决策人NearestHumanInThread
// 不是一个地址 —— 抄送也不应当收到它(权限是待办,不是广播)。
//
// 但 payload 必须带足字段:前端的授权页靠 session_alias + 会话 workspace
// 拼出「哪个 Agent、在哪个目录、哪条线索」。只给 from_name 的话人
// 看到的只是一个光秃的 Agent 名,无法判断该不该批。
alias := repo.SessionAliasOf(r.Context(), sessionID)
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": agentName,
"subject": mailSubjectFor(kind, req.Question),
"mail_type": "permission_request",
"role": "to",
"session_alias": alias,
// 待办类型与多选语义必须随推送下发:前端靠它们决定渲染
// 「批准/拒绝」还是「回答问题」(勾选 + 自由文本)。
// 不下发的话前端只能重查一次,而授权页是靠这条推送实时更新的。
"permission_kind": kind,
"permission_multi_select": req.MultiSelect,
"permission_options": options,
})
JSON(w, http.StatusOK, map[string]string{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"permission_mail_id": mailID.String(),
"decider": decider,
})
}
// mailSubjectFor 给待办邮件起主题。
//
// 审批型与主动询问是两类不同的待办,主题必须一眼能区分:「权限请求」意味着
// 有人要被放行一个危险操作,「需要回答」只是模型缺信息。混用一套措辞会让人
// 在授权页里把「回答问题」当成「批准执行」。
func mailSubjectFor(kind, question string) string {
if kind == "question" {
return "需要回答: " + question
}
return "权限请求: " + question
}
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
func DecidePermission(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req permissionDecideRequest
if !DecodeBody(w, r, &req) {
return
}
if req.MailID == "" || req.Decision == "" {
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
return
}
mailID, err := uuid.Parse(req.MailID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
return
}
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Permission request not found")
return
}
if perm.Result != nil && *perm.Result != "" {
Error(w, http.StatusConflict, "该请求已被处理")
return
}
// 鉴权:必须是这封权限邮件的收件人,或管理员
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
if !user.IsAdmin() && mail.ToName != user.Username {
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
return
}
// 决策选项必须在候选内 —— 仅限审批型。主动询问允许自由文本回答,
// 多选时 decision 是多个原始标签(前端用换行分隔),同样不套暂时选项表。
if perm.Kind != "question" && !contains(perm.Options, req.Decision) {
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
return
}
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
Error(w, http.StatusInternalServerError, "Failed to decide permission")
return
}
decisionMailID, err := repo.CreateDecisionMail(
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
return
}
// 通知发起 Agent 恢复执行
// 带上上游 permission id插件要拿它回复 opencode 的原生权限询问。
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
payload := map[string]interface{}{
"mail_id": mailID.String(),
"decision_mail_id": decisionMailID.String(),
"decision": req.Decision,
"note": req.Note,
"decided_by": user.Username,
"kind": perm.Kind,
"multi_select": perm.MultiSelect,
// 会话 id插件重启丢了待决映射时会退化成「把决策当一封通知投进会话」
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
"session_id": perm.SessionID.String(),
}
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
payload["relay_key"] = key
payload["relay_kind"] = kind
}
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
// 只刷新决策人自己的界面
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
"session_id": perm.SessionID.String(),
"status": "active",
})
// 已越过等待窗口的决策必须当场说清「这次批准不会恢复原调用」。
//
// 实测2026-09-11pi 桥等不到决策时,会在回合超时(默认 10 分钟)拆掉
// worker 与它的决策路由表;此后再来的决策只会作为**通知**投递给 Agent
// 不会恢复当时那次工具调用(该轮已经结束了)。
//
// 而接口原本照旧回 200 {"status":"decided"} —— 人在界面上看到批准成功,
// 实际那件事什么都没发生。这正是 I-5 要消灭的「静默成功」。
// 具体组装见 decideResponse。
JSON(w, http.StatusOK, decideResponse(perm.CreatedAt, time.Now(), decisionMailID.String()))
}
// decideResponse 组装权限决策的响应体。
//
// 抽成纯函数是为了可测:那条「越过等待窗口」的分支只有等满窗口才会走到,
// 不能只靠人工点一遍;而它正是「人看到批准成功、实际什么都没发生」的根源。
//
// 越窗时加 expired + warning 而**不**改 HTTP 状态码:决策本身仍是人的真实意愿、
// 仍然有效(桥会把它当通知投给 AgentAgent 重起一轮),所以不能拒掉;
// 但必须把发生了什么讲明白。
//
// 这里用布尔值而不是让客户端自己比:响应本身就是「此刻」的一次性快照,
// 不像邮件那样会被缓存反复展示。(邮件上给的则是失效**时刻**,见 models.Mail。
func decideResponse(createdAt, now time.Time, decisionMailID string) map[string]any {
resp := map[string]any{
"status": "decided",
"decision_mail_id": decisionMailID,
}
if deadline := models.PermissionDeadline(createdAt); now.After(deadline) {
resp["expired"] = true
resp["expires_at"] = deadline
resp["warning"] = fmt.Sprintf(
"该请求已超过等待窗口(%.0f 分钟),发起它的 Agent 很可能已不再阻塞等待。"+
"决策已记录并会投递给它,但不会恢复当时那次工具调用 —— "+
"它会把这次决策当作一条通知,重新起一轮。",
models.PermissionWaitWindow.Minutes())
}
return resp
}
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
forUser := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
forUser = ""
}
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"requests": emptySlice(reqs),
})
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}