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。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

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

View File

@ -266,11 +266,14 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error)
err := db.DB.QueryRowContext(ctx,
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id,
created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'),
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0)
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0),
COALESCE(NULLIF(permission_mode, ''), 'workspace'),
COALESCE(NULLIF(permission_enforcement, ''), 'advisory')
FROM sessions WHERE session_id = $1`, id,
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID,
&s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource,
&s.MaxRounds, &s.UsedRounds)
&s.MaxRounds, &s.UsedRounds,
&s.PermissionMode, &s.PermissionEnforcement)
if err != nil {
return nil, err
}
@ -299,40 +302,6 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
return err
}
func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) {
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
s.owner_user_id, s.created_at, s.updated_at,
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
FROM sessions s`
args := []any{}
if statusFilter != "" {
q += ` WHERE s.status = $1`
args = append(args, statusFilter)
}
q += ` ORDER BY s.updated_at DESC`
if limit > 0 {
q += fmt.Sprintf(` LIMIT %d`, limit)
}
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
sessions := []models.Session{}
for rows.Next() {
var s models.Session
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, rows.Err()
}
// ---------- Mail ----------
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
@ -393,6 +362,21 @@ func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID u
return id, err
}
// DeleteMailByID 删一封邮件。
//
// **只用于回滚一次刚失败的发信**,不是给人用的「删邮件」功能 ——
// 邮件是不可篡改的历史记录,没有任何人面入口能删它。
//
// 场景:附件挂载在建邮件之后才发现冲突(竞态窗口),此时这封邮件不应存在:
// 发件方收到的是 4xx它会重试而一封无附件的残余邮件会让收件方收到两封。
//
// attachments 表的外键是 ON DELETE CASCADE所以已经挂上去的那几条会跟着消失
// relayed_mails 的 mail_id 无 CASCADE由调用方用 ReleaseRelay 归还幂等键。
func DeleteMailByID(ctx context.Context, id uuid.UUID) error {
_, err := db.DB.ExecContext(ctx, `DELETE FROM mails WHERE mail_id = $1`, id)
return err
}
func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
var m models.Mail
var alias *string
@ -402,14 +386,17 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.mail_id = $1`, id,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason)
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason,
&m.FromHuman, &m.ToHuman)
if err != nil {
return nil, err
}
@ -443,7 +430,9 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
COALESCE(NULLIF(s.permission_mode, ''), 'workspace') AS permission_mode,
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory') AS permission_enforcement
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
@ -472,7 +461,8 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman,
&m.PermissionMode, &m.PermissionEnforcement); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
@ -513,7 +503,9 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1
@ -531,7 +523,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
@ -632,13 +624,15 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
FROM mails m JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace)
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman)
if err != nil {
return nil, err
}
@ -704,6 +698,18 @@ func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UU
// mails.to_workspace 反推。只看 to_workspaceAgent 回信时 from_workspace 存的是
// Agent 名而不是路径,拿它比路径永远匹配不上(旧实现就挂在这里)。
func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) {
id, _, err := FindOrCreateDefaultSessionCreated(ctx, name, path, fromAgent, subject)
return id, err
}
// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回
// **这次调用是否真的新建了会话**。
//
// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」,
// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段
// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。
// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。
func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
@ -729,12 +735,13 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj
`, name, path).Scan(&id)
if err == nil {
TouchSession(ctx, id)
return id, nil
return id, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, err
return uuid.Nil, false, err
}
return CreateSession(ctx, nil, fromAgent, subject, path)
newID, cErr := CreateSession(ctx, nil, fromAgent, subject, path)
return newID, cErr == nil, cErr
}
// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。
@ -889,6 +896,12 @@ type Contact struct {
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
// PermissionMode 与 PermissionEnforcement 必须成对出现在列表上:
// 前者是「这条任务要求什么」,后者是「对方平台实际做到了什么」。
// 只显示前者会让人以为 plan 档把 homeagent 管住了(它没有拦截点)。
PermissionMode string `json:"permission_mode"`
PermissionEnforcement string `json:"permission_enforcement"`
// LastFrom/LastPreview 是最后一封邮件的发件人与正文摘要,
// 卡片视图用它显示「最新进展」——列表视图只显示地址时,
// 人必须逐条点开才知道哪条有新动静。
@ -961,6 +974,8 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
s.subject,
COALESCE(s.max_rounds, 0),
COALESCE(s.used_rounds, 0),
COALESCE(NULLIF(s.permission_mode, ''), 'workspace'),
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'),
COALESCE((SELECT from_name FROM mails WHERE mail_id = `+lastMail+`), ''),
COALESCE((SELECT body FROM mails WHERE mail_id = `+lastMail+`), '')
FROM sessions s`+firstMail+`
@ -978,6 +993,7 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias,
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity,
&c.Subject, &c.MaxRounds, &c.UsedRounds,
&c.PermissionMode, &c.PermissionEnforcement,
&c.LastFrom, &c.LastPreview); err != nil {
return nil, err
}
@ -1141,7 +1157,9 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.from_name = $1 AND s.status <> 'archived'
@ -1161,7 +1179,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
@ -1223,6 +1241,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
s.owner_user_id, s.created_at, s.updated_at,
COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0),
COALESCE(NULLIF(s.permission_mode, ''), 'workspace'),
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'),
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
FROM sessions s
WHERE s.status <> 'archived'`
@ -1255,7 +1275,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
// 结果 /me/sessions 整个 500联系人栅拉不到任何数据。
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
&s.MaxRounds, &s.UsedRounds,
&s.PermissionMode, &s.PermissionEnforcement, &s.MailCount); err != nil {
return nil, err
}
sessions = append(sessions, s)