L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
149 lines
6.5 KiB
Go
149 lines
6.5 KiB
Go
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)
|
||
}
|