Files
MailUI4Agents/gateway/internal/repo/permission_mode.go
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

149 lines
6.5 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 repo
// 会话级权限档位的读写。
//
// ## 为什么档位挂在会话上而不是每封邮件上
//
// 与配额同一个理由(见 quota.go 的注释):档位是**任务**的属性。
// 「这件事只许你看不许你动」描述的是任务性质,不是某一封信的性质。
//
// 如果续谈的邮件也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 ——
// 而 plan 档的会话里模型已经被告知「只许看」,第二封信把它改成 full
// 是在一段已有上下文里换规则。人不一定意识到自己改了。
//
// 所以:**新建会话时设,续谈时忽略该字段,在对话页里显式编辑。**
//
// ## 为什么 Agent 不能自己指定档位
//
// 否则 Agent 发一封 mode=full 的信就给自己提权了。Agent 派活时子会话的档位
// 由 InheritedMode 从父会话推导,且**只能同档或更严**models.ModeAtMost
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 一个形状。
import (
"context"
"fmt"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// SessionPermission 是一条会话的档位与实际强制力。
//
// 两个字段必须一起返回:档位是「要求什么」,强制力是「平台实际做到了什么」。
// 只给前者会让人以为 plan 档管住了 homeagent它的核心没有工具调用拦截点
type SessionPermission struct {
Mode string `json:"permission_mode"`
Enforcement string `json:"permission_enforcement"`
}
// GetSessionPermission 读一条会话的档位与强制力。
//
// 读出来的值一律过 Normalize库里可能有历史脏数据手工改库、旧版本写入
// 而调用方拿到一个认不出的档位时的行为无法预期。归一化在这里做一次,
// 后续所有判断就都能假定值是合法的。
func GetSessionPermission(ctx context.Context, id uuid.UUID) (SessionPermission, error) {
var p SessionPermission
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(NULLIF(permission_mode, ''), 'workspace'),
COALESCE(NULLIF(permission_enforcement, ''), 'advisory')
FROM sessions WHERE session_id = $1`, id).Scan(&p.Mode, &p.Enforcement)
if err != nil {
return SessionPermission{}, err
}
p.Mode = models.NormalizePermissionMode(p.Mode)
p.Enforcement = models.NormalizeEnforcement(p.Enforcement)
return p, nil
}
// SessionPermissionMode 只取档位,读不到时回落默认档。
//
// 供投递路径使用:那里拿不到档位也得继续走(不能因为查询失败就拒收邮件),
// 但回落必须是默认档而不是 full —— 查询失败不该换来更大的权限。
func SessionPermissionMode(ctx context.Context, id uuid.UUID) string {
p, err := GetSessionPermission(ctx, id)
if err != nil {
return models.DefaultPermissionMode
}
return p.Mode
}
// SetSessionPermissionMode 设置会话档位。
//
// 非法档位一律收敛成默认档而不是报错:这个函数的调用方包括人在界面上操作,
// 而界面传来一个拼错的值时,静默用默认档比让整次操作失败更合理 ——
// 默认档本身是安全的。
func SetSessionPermissionMode(ctx context.Context, id uuid.UUID, mode string) (SessionPermission, error) {
m := models.NormalizePermissionMode(mode)
tag, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET permission_mode = $2, updated_at = NOW() WHERE session_id = $1`,
id, m)
if err != nil {
return SessionPermission{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return SessionPermission{}, fmt.Errorf("会话 %s 不存在", id)
}
return GetSessionPermission(ctx, id)
}
// SetSessionEnforcement 记录接收平台实际做到的强制力。
//
// 由投递路径在建会话时按收件 Agent 的自报能力写入 —— 它是**事实快照**
// 而不是配置:插件升级后能力会变,但已结束的会话不该被改写成「其实当时
// 是被强制的」。所以不跟着 agents.mode_enforcement 走,而是建会话时定死。
func SetSessionEnforcement(ctx context.Context, id uuid.UUID, enforcement string) error {
e := models.NormalizeEnforcement(enforcement)
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET permission_enforcement = $2 WHERE session_id = $1`, id, e)
return err
}
// AgentModeEnforcement 取某个 Agent 自报的档位强制力。
//
// Agent 不存在或没自报过时返回 advisory不能替一个没说过话的插件宣称
// 「档位在它那里是被强制的」。保守方向是承认做不到。
func AgentModeEnforcement(ctx context.Context, agentName string) string {
var e string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(NULLIF(mode_enforcement, ''), 'advisory')
FROM agents WHERE agent_name = $1`, agentName).Scan(&e)
if err != nil {
return models.EnforcementAdvisory
}
return models.NormalizeEnforcement(e)
}
// SetAgentModeEnforcement 落库 Agent 心跳自报的档位强制力。
//
// 走心跳而不是注册:注册只在插件启动时发生一次,而能力可能因为配置变化
// (比如 DSH 的 sandbox 被换成 danger-full-access而改变。与模型目录上报
// 同一条通道 —— I-1平台自己说的才算。
func SetAgentModeEnforcement(ctx context.Context, agentName, enforcement string) error {
e := models.NormalizeEnforcement(enforcement)
_, err := db.DB.ExecContext(ctx,
`UPDATE agents SET mode_enforcement = $2 WHERE agent_name = $1`, agentName, e)
return err
}
// InheritedMode 推导子会话应当继承的档位。
//
// parentSessionID 为 nil人直接发起、或没有父会话可依据时返回 requested
// 归一化后的值;有父会话时取**父档位与请求档位里更严的那一个**。
//
// 为什么必须取更严Agent 派活时若能给子会话一个更宽松的档位plan 档的
// 任务就能通过「派给自己一条 full 档子会话」来提权,档位形同虚设。
// 这与 hop_limit 防自激的形状一样 —— 约束必须沿着链条传递下去。
func InheritedMode(ctx context.Context, parentSessionID *uuid.UUID, requested string) string {
req := models.NormalizePermissionMode(requested)
if parentSessionID == nil {
return req
}
parent, err := GetSessionPermission(ctx, *parentSessionID)
if err != nil {
// 父会话查不到时按默认档与请求档取更严 —— 不能因为查询失败而放宽。
return models.ModeAtMost(models.DefaultPermissionMode, req)
}
return models.ModeAtMost(parent.Mode, req)
}