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

@ -95,6 +95,21 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
// 旧库也给 20之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
// 不能直接搬过来当单任务预算。
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
// 会话级权限档位plan / workspace / full
//
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
// 接收平台实际做到的强制力native / advisory由插件心跳自报后落到会话上。
//
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
// 保守方向是承认做不到,而不是假装做到了。
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
// Agent 自报的档位强制能力native / advisory随心跳更新。
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
}
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。

View File

@ -94,6 +94,22 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
-- 会话级权限档位plan / workspace / full
--
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace';
-- 接收平台实际做到的强制力native / advisory由插件心跳自报后落到会话上。
-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory';
-- Agent 自报的档位强制能力native / advisory随心跳更新。
-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力,
-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory';
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- Mails table

View File

@ -63,6 +63,17 @@ CREATE TABLE IF NOT EXISTS agents (
-- max_rounds 保留列但不再参与判断。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
-- mode_enforcement 是该平台插件自报的权限档位强制能力native / advisory
-- 随心跳上报(与模型目录同一条通道 —— I-1平台自己说的才算
--
-- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
-- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
-- 把这个事实藏起来比做不到本身更危险。
--
-- 默认 advisory 而不是 native没自报过的插件我们不能替它宣称
-- 「档位在这里是被强制的」。
mode_enforcement TEXT NOT NULL DEFAULT 'advisory',
last_seen DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
@ -115,7 +126,18 @@ CREATE TABLE IF NOT EXISTS sessions (
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
-- 每条都是全新预算,全局上限就形同虚设。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0
used_rounds INTEGER NOT NULL DEFAULT 0,
-- 会话级权限档位plan / workspace / full
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
permission_mode TEXT NOT NULL DEFAULT 'workspace',
-- 接收平台实际做到的强制力native / advisory
-- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称
-- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。
permission_enforcement TEXT NOT NULL DEFAULT 'advisory'
);
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);

View File

@ -47,15 +47,26 @@ type heartbeatRequest struct {
// 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略:
// 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。
Models []repo.CatalogModel `json:"models"`
// ModeEnforcement 是插件自报的权限档位强制能力native / advisory。
//
// 为什么走心跳而不是注册能力会在运行中变。DSH 的沙箱模式被改成
// danger-full-access 时,它就从 native 退化成了 advisory实测
// approval:"never" 会在 waterfall 之前短路approval/request 根本不触发)。
// 只在注册时报一次的话,发件人看到的是上次重启时的能力快照。
//
// 与模型目录同一条通道I-1平台自己说的才算
// 省略 = 本次不上报,保留现有值(与 PlatformSessions / Models 同约定)。
ModeEnforcement string `json:"mode_enforcement"`
}
// POST /api/v1/agent/register
//
// 两种认证方式:
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
// 否则等于拿别人的密钥冒充新身份。
// 2. body 里带 secret —— 旧方式,兼容保留。
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
// 否则等于拿别人的密钥冒充新身份。
// 2. body 里带 secret —— 旧方式,兼容保留。
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if !DecodeBody(w, r, &req) {
@ -161,9 +172,14 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
// 不该因为上报体格式不对就把 Agent 判成离线。
//
// 但**未知字段必须回报**resp["unknown_fields"]):这是全站唯一一处宽容
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
var req heartbeatRequest
var unknownFields []string
if r.ContentLength > 0 {
_ = Decode(r, &req)
unknownFields, _ = DecodeLenient(r, &req)
}
syncedSessions := -1 // -1 = 本次未上报
if req.PlatformSessions != nil {
@ -183,6 +199,12 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
}
}
// 档位强制能力:省略时不动(保留现有值)。
// 写失败不影响心跳本身 —— 心跳的主职责是「我还活着」。
if req.ModeEnforcement != "" {
_ = repo.SetAgentModeEnforcement(r.Context(), agentName, req.ModeEnforcement)
}
// 心跳回传该 Agent 的累计统计与新任务默认预算。
//
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent
@ -212,6 +234,11 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
resp["allowed_models"] = allowed
resp["models_unrestricted"] = len(allowed) == 0
}
// 未知字段回报:只有真的出现时才带这一项,正常心跳的响应不多一个空数组。
// 插件看到它就知道自己上报的某个字段服务端根本没收。
if len(unknownFields) > 0 {
resp["unknown_fields"] = unknownFields
}
JSON(w, http.StatusOK, resp)
}

View File

@ -127,7 +127,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
subject := forwardSubject(req.Subject, src.Subject)
// 转发按目标地址寻址,不带 reply_to它是一条新线索不该并进原会话
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
sessionID, _, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
if err != nil {
writeErr(w, err, "Failed to resolve session")
return

View File

@ -37,6 +37,16 @@ type sendMailRequest struct {
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
Relay string `json:"relay"` // "" | "permission" | "summary"
RelayKey string `json:"relay_key"` // 上游消息 idrelay 非空时必填
// FromSessionID 是发信时模型所处的邮件会话 id即「这活是谁派给我的」
//
// **只用于权限档位继承**Agent 新开一条会话时,新会话不得比它所在
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
//
// 省略时回落到默认档(不是 full。插件担不担得起传这个值不影响安全下限
// 没传 = 拿默认档,不会因此拿到更大的权限。
FromSessionID string `json:"from_session_id"`
}
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
@ -52,18 +62,28 @@ type sendMailRequest struct {
// byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制:
// 往返预算按会话计Agent 用 .new 开一串会话就等于绕过预算。
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) {
// resolveTarget 依据地址的 session 位定位(或新建)会话。
//
// 返回值:会话 id / 父邮件 id仅 reply_to 路径非 nil/ **created** / 错误。
//
// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是:
// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 ——
// 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果
// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。
// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断,
// 否则每封新信都在改写对方正在遵守的规则。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) {
if replyTo != "" {
replyID, err := uuid.Parse(replyTo)
if err != nil {
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID")
}
mail, err := repo.GetMailByID(r.Context(), replyID)
if err != nil {
return uuid.Nil, nil, errNotFound("Parent mail not found")
return uuid.Nil, nil, false, errNotFound("Parent mail not found")
}
repo.TouchSession(r.Context(), mail.SessionID)
return mail.SessionID, &replyID, nil
return mail.SessionID, &replyID, false, nil
}
switch addr.Mode() {
@ -73,17 +93,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
var aliasPtr *string
if a := strings.TrimSpace(alias); a != "" {
if err := validateSessionAlias(a); err != nil {
return uuid.Nil, nil, err
return uuid.Nil, nil, false, err
}
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
return uuid.Nil, nil, errConflict(fmt.Sprintf(
return uuid.Nil, nil, false, errConflict(fmt.Sprintf(
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
}
aliasPtr = &a
}
// Agent 主动开新线索要过速率限制
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
return uuid.Nil, nil, errRateLimited(fmt.Sprintf(
return uuid.Nil, nil, false, errRateLimited(fmt.Sprintf(
"新建会话过于频繁1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
repo.SessionRateLimit(), retry))
}
@ -93,7 +113,7 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
if err != nil {
// 建失败要把名额还回去:那次新建实际上没有发生
repo.ReleaseNewSession(r.Context(), byAgent)
return id, nil, err
return id, nil, false, err
}
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
// `name@path.<别名>`。未命名会话既查不到FindNamedSessionFor 的
@ -105,29 +125,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
// 只能用 reply_to 续谈,比整封退回轻。
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
}
return id, nil, nil
return id, nil, true, nil
case models.SessionDefault:
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
// 不构成暴开的手段,因此不计入速率限制。
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
//
// created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回
// parentMailID == nil靠它判断会把续谈误当新建预算与档位被静默改写
id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject)
if err != nil {
return id, nil, err
return id, nil, false, err
}
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
return id, nil, nil
return id, nil, created, nil
default: // models.SessionNamed
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
if err == nil {
repo.TouchSession(r.Context(), id)
return id, nil, nil
return id, nil, false, nil
}
if !errors.Is(err, repo.ErrSessionNotFound) {
return uuid.Nil, nil, err
return uuid.Nil, nil, false, err
}
// 本侧没有这条别名 —— 再看平台会话镜像。
@ -138,13 +161,15 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
//
// 命中就**接管**它:本侧建一条会话并绑定 platform_id插件收到投递
// 事件时据此 resume 那条平台会话而不是新建。
// 接管**是**新建本侧会话(绑定了 platform_id 的那条),
// 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
return adopted, nil, nil
return adopted, nil, true, nil
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
return uuid.Nil, nil, aErr
return uuid.Nil, nil, false, aErr
}
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
return uuid.Nil, nil, false, errNotFound(fmt.Sprintf(
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new投递默认会话请省略 session 位",
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
}
@ -236,18 +261,56 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
// 附件可挂性必须在**建邮件之前**校验。
//
// 原先只在 CreateMail 之后调 attachAll于是附件不合法时返回 403/409
// 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。
// 发件方看到 4xx 会重试,收件方于是收到两封。
if !checkAttachable(w, r, attachIDs, agentName) {
return
}
// 可达性收件人必须存在且未停用。Agent 侧同样要查 ——
// 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// Agent 新开的会话继承权限档位,**不得自行抬档**。
//
// req 里根本没有 permission_mode 字段 —— 这是有意的Agent 能指定档位
// 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话
// (也就是「这活是谁派给我的」)推导,且只能同档或更严。
//
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形:
// 约束必须沿着链条传递下去,否则一跳之后就失效了。
// 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil
// 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经
// 被人在对话页里改过,重继承等于把人的修改静默回滚。
if created {
// 发信方自己那条会话的档位是上限。插件没传 from_session_id 时
// 回落到默认档 —— 不会因为没传而拿到更大的权限。
var parent *uuid.UUID
if req.FromSessionID != "" {
if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil {
parent = &pid
}
}
mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode)
if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
// 配额在建邮件之前扣:否则邮件已入库再报 403收件方会看到一封发件方以为发失败的邮件。
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
//
@ -373,6 +436,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
}
if !attachAll(w, r, mailID, attachIDs, agentName) {
// 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个
// 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的
// 邮件,而发件方以为整次请求失败了。
//
// 三件事都要退邮件本身、本次往返预算、relay 幂等键。
// 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。
_ = repo.DeleteMailByID(r.Context(), mailID)
if !relayFree {
repo.RefundSessionBudget(r.Context(), sessionID)
}
if relay != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
return
}

View File

@ -14,8 +14,8 @@ import (
// ---------- /me当前登录人类用户的邮箱全部路由需 UserAuth ----------
type meSendMailRequest struct {
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
@ -30,6 +30,16 @@ type meSendMailRequest struct {
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
// PUT /sessions/{id}/budget对话页里可随时改
MaxRounds *int `json:"max_rounds"`
// PermissionMode 声明本任务允许 Agent 动手到什么程度plan / workspace / full。
//
// 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受
// 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里
// 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。
// 续谈请用 PUT /sessions/{id}/permission对话页里可随时改
//
// 省略时用 models.DefaultPermissionModeworkspace
PermissionMode string `json:"permission_mode"`
}
// POST /api/v1/me/mail/send
@ -65,6 +75,11 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
// 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go
if !checkAttachable(w, r, attachIDs, user.Username) {
return
}
// human@ 是兼容别名,人类发信时解析为自己
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
@ -82,7 +97,24 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
// 纯输入校验必须在建会话【之前】做完。
//
// 原来两项校验都在 resolveTarget 之后:请求返回 400但 `.new` 已经建好了
// 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封
// 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。
rounds := -1
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
}
if !validPermissionModeInput(w, req.PermissionMode) {
return
}
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
@ -95,21 +127,30 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
//
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
// 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级。
if parentMailID == nil {
rounds := 0
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
} else {
// 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用
// 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值
//实测max_rounds=7 的会话被第二封省略该字段的信改成 20
if created {
if rounds < 0 {
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
// 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头,
// 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。
mode := models.NormalizePermissionMode(req.PermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
// 强制力是事实快照:按收件 Agent 当下自报的能力定死。
// 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory
// 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
@ -124,6 +165,9 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
}
if !attachAll(w, r, mailID, attachIDs, user.Username) {
// 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。
// 人类发信不扣会话预算、也不走 relay所以只需退邮件本身。
_ = repo.DeleteMailByID(r.Context(), mailID)
return
}

View File

@ -6,6 +6,7 @@ import (
"strings"
"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"
@ -98,7 +99,47 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
sessionID = id
}
// 决策人:显式指定优先,否则取会话 owner
// 权限档位决定这次询问该不该存在。
//
// 只有 workspace 档需要人:
// - plan 档 → 409。该档的语义就是「这轮不动手」没什么可问人的
// 模型该做的是把方案写在回信里。
// - full 档 → 409。已经声明全权再问一遍只是噪音插件本不该发这封信
// 发了说明它没按档位翻译,报错比静默接受好。
//
// 这也是为什么下面不再有「退回第一个管理员」的兜底:
// 既然只有一档需要人,那一档里找不到人就是 409没有中间形态。
mode := repo.SessionPermissionMode(r.Context(), sessionID)
if !models.ModeNeedsHuman(mode) {
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 := req.To
if decider == "" || decider == "human" {
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
@ -106,15 +147,6 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
decider = owner
}
}
if decider == "" {
// 会话无归属Agent 自发起)时退回默认管理员
admin, err := repo.FirstAdminUsername(r.Context())
if err != nil || admin == "" {
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
return
}
decider = admin
}
// 关键防线decider 必须是人类用户。
//
@ -130,7 +162,13 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
decider = human
} else {
// 整条任务链上没有人类Agent → Agent → Agent中间没有任何人介入。
// 此时把权限请求转给管理员毫无意义 —— 管理员对这条 Agent 链的上下文一无所知,
//
// 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底,
// 把 decider 填成 adminIsHumanUser 于是通过,这里根本不会被调用。
// 实测pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
// 那段兜底已删(参见上面的档位判定)。
//
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
//
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:

View File

@ -25,6 +25,14 @@ type Agent struct {
UsedRounds int `json:"used_rounds"`
LastSeen *time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
// ModeEnforcement 是该平台插件自报的权限档位强制能力native / advisory
// 随心跳上报(与模型目录同一条通道,见 I-1平台自己说的才算
//
// 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
// homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
// 把这个事实藏起来比做不到本身更危险。
ModeEnforcement string `json:"mode_enforcement"`
}
// Workspace 是 Agent 管理的项目工作区
@ -60,6 +68,14 @@ type Session struct {
// 所以在写信时给、在对话页里随时调。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
// PermissionMode 声明本任务允许 Agent 动手到什么程度:
// plan / workspace / full。空值按 DefaultPermissionMode 处理。
PermissionMode string `json:"permission_mode"`
// PermissionEnforcement 记录接收平台是否真正强制了权限档位:
// native / advisory。它描述执行事实不与 PermissionMode 混为一谈。
PermissionEnforcement string `json:"permission_enforcement"`
}
// User 是人类用户(多用户账号体系)
@ -173,6 +189,25 @@ type Mail struct {
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
FromHuman bool `json:"from_human"`
// ToHuman 表示收件方是人类用户而不是 Agent判据与 FromHuman 同源:
// to_name 是否存在于 users 表)。
//
// 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字,
// Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 ——
// 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留),
// 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。
ToHuman bool `json:"to_human"`
// PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。
//
// **补拉路径必须有它们**(与 FromHuman 同一个理由SSE 事件里叫
// `permission_mode` / `permission_enforcement`,而插件重启后走
// `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件
// 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后
// 惄惄变成了 workspace 档。
PermissionMode string `json:"permission_mode"`
PermissionEnforcement string `json:"permission_enforcement"`
}
// PermissionRequest 是 Agent 向人类发起的权限请求

View File

@ -0,0 +1,142 @@
package models
// ─── 权限档位 ───
//
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1平台原生
// 信号是唯一真相来源)与 I-4插件只搬运不决策而且四个插件对「workspace
// 到底管什么」必然各猜一套。
//
// 档位与 DSH 原生的三档沙箱一一对应read-only / workspace-write /
// danger-full-access见 @deepseek-ai/dsh-sandbox-policy—— 那不是巧合,
// 是同一个问题的同一个答案。
const (
// ModePlan 只读:查资料、读代码、出方案,一个字都不许写。
//
// 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」,
// 没什么可问人的。模型该做的是把方案写在回信里。
ModePlan = "plan"
// ModeWorkspace 本目录内可动手,越界要问人。默认档。
//
// 「本目录」= 会话的 workspace三维地址的 path 位)。越界的定义是
// 写到那个目录之外,或跑一条无法判定影响范围的命令。
ModeWorkspace = "workspace"
// ModeFull 自动放行,不问人。
//
// 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。
ModeFull = "full"
)
// DefaultPermissionMode 是没有显式指定时的档位。
//
// 选 workspace 而不是 full默认值应当是「多数任务够用且出错代价可控」的那一档。
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
const DefaultPermissionMode = ModeWorkspace
// PermissionModes 是全部合法档位,按宽松程度递增排列。
//
// 顺序有意义ModeAtMost 靠它做「向更严取整」。
var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull}
// ValidPermissionMode 判断是不是合法档位。
func ValidPermissionMode(m string) bool {
for _, v := range PermissionModes {
if v == m {
return true
}
}
return false
}
// NormalizePermissionMode 把外部输入收敛成合法档位。
//
// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull
// 拼错一个档位名不该换来比预期更大的权限。
func NormalizePermissionMode(m string) string {
if ValidPermissionMode(m) {
return m
}
return DefaultPermissionMode
}
// modeRank 是档位的宽松程度序号,越大越宽松。
//
// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。
// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0plan
// 而 NormalizePermissionMode 把它归到 workspace于是同一个脏值在不同函数里
// 含义不同ModeAtMost 也因此不可交换(单元测试当场抓到)。
func modeRank(m string) int {
for i, v := range PermissionModes {
if v == m {
return i
}
}
// 归一化后不可能走到这里;防御性地返回默认档的序号。
return modeRank(DefaultPermissionMode)
}
// ModeAtMost 返回 a 与 b 里更严的那一档。
//
// 两个用途:
// - 子会话继承Agent 派活时子会话不得比父会话宽松plan 档派不出 full 档子任务)
// - 平台取整:平台表达不出精确档位时向更严的方向取整
//
// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。
//
// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换),
// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。
func ModeAtMost(a, b string) string {
na := NormalizePermissionMode(a)
nb := NormalizePermissionMode(b)
if modeRank(na) <= modeRank(nb) {
return na
}
return nb
}
// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。
//
// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」:
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档
// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。
//
// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉:
// 它让 409 分支永远不可达实测pi 给自己派活跑 bash权限邮件发给了 jianf
// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。
func ModeNeedsHuman(m string) bool {
return NormalizePermissionMode(m) == ModeWorkspace
}
// ─── 强制力 ───
//
// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且
// 都对人可见I-5失败必须可见—— 否则发件人以为 plan 档管住了 homeagent
// 而 homeagent 的核心根本没有工具调用拦截点。
const (
// EnforcementNative 平台有原生拦截点,档位被真正执行。
EnforcementNative = "native"
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
//
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
EnforcementAdvisory = "advisory"
)
// ValidEnforcement 判断强制力取值是否合法。
func ValidEnforcement(e string) bool {
return e == EnforcementNative || e == EnforcementAdvisory
}
// NormalizeEnforcement 收敛强制力取值。
//
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
func NormalizeEnforcement(e string) string {
if ValidEnforcement(e) {
return e
}
return EnforcementAdvisory
}

View File

@ -0,0 +1,160 @@
package models
// 权限档位的判据测试。
//
// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整),
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
// 取向(非法值 → workspace 而非 full是安全属性拼错一个档位名不该换来更大权限。
import "testing"
func TestValidPermissionMode(t *testing.T) {
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
if !ValidPermissionMode(m) {
t.Fatalf("%q 应当合法", m)
}
}
for _, m := range []string{"", "PLAN", "readonly", "danger-full-access", "workspace-write"} {
if ValidPermissionMode(m) {
t.Fatalf("%q 不该合法", m)
}
}
}
// 非法值必须落到 workspace不能落到 full。
// 拼错一个档位名换来全权是最不该有的失败方向。
func TestNormalizePermissionMode_FailsClosed(t *testing.T) {
for _, in := range []string{"", "full-access", "plan ", "FULL", "无", "workspace-write"} {
got := NormalizePermissionMode(in)
if got != DefaultPermissionMode {
t.Fatalf("NormalizePermissionMode(%q) = %q应当是默认档 %q", in, got, DefaultPermissionMode)
}
}
if DefaultPermissionMode == ModeFull {
t.Fatal("默认档不能是 full —— 「我忘了收紧」与「我确实需要全权」会无法区分")
}
}
func TestNormalizePermissionMode_KeepsValid(t *testing.T) {
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
if got := NormalizePermissionMode(m); got != m {
t.Fatalf("合法档位应原样返回:%q → %q", m, got)
}
}
}
// ModeAtMost 取更严的一档 —— 子会话继承与平台取整共用这一个判据。
func TestModeAtMost(t *testing.T) {
cases := []struct{ a, b, want string }{
{ModePlan, ModeFull, ModePlan},
{ModeFull, ModePlan, ModePlan},
{ModeWorkspace, ModeFull, ModeWorkspace},
{ModeFull, ModeWorkspace, ModeWorkspace},
{ModePlan, ModeWorkspace, ModePlan},
{ModeWorkspace, ModePlan, ModePlan},
{ModeFull, ModeFull, ModeFull},
{ModePlan, ModePlan, ModePlan},
{ModeWorkspace, ModeWorkspace, ModeWorkspace},
}
for _, c := range cases {
if got := ModeAtMost(c.a, c.b); got != c.want {
t.Fatalf("ModeAtMost(%q,%q) = %qwant %q", c.a, c.b, got, c.want)
}
}
}
// 未知值归到默认档workspace而不是最严的 plan。
//
// 为什么不是 plan脏数据的含义应该在整个包里只有一个 ——
// NormalizePermissionMode / ModeNeedsHuman 都把它当默认档ModeAtMost
// 若单独把它当 plan同一个脏值就有两种语义且 ModeAtMost 不可交换
// (单元测试当场抓到过)。一致比“局部更严”重要:默认档本身已经是安全的。
func TestModeAtMost_UnknownFallsToDefault(t *testing.T) {
if got := ModeAtMost("garbage", ModeFull); got != DefaultPermissionMode {
t.Fatalf("未知档位应归默认档,得到 %q", got)
}
if got := ModeAtMost(ModeFull, "garbage"); got != DefaultPermissionMode {
t.Fatalf("未知档位应归默认档,得到 %q", got)
}
// 脏值不得抬升权限:与 plan 相遇时仍然是 plan 胜出。
if got := ModeAtMost("garbage", ModePlan); got != ModePlan {
t.Fatalf("脏值不该把 plan 抬成更宽松的档,得到 %q", got)
}
}
// ModeAtMost 必须可交换:两处调用点传参顺序不同,结果不能不同。
func TestModeAtMost_Commutative(t *testing.T) {
all := append([]string{"garbage", ""}, PermissionModes...)
for _, a := range all {
for _, b := range all {
if ModeAtMost(a, b) != ModeAtMost(b, a) {
t.Fatalf("ModeAtMost 不可交换:(%q,%q)=%q 但 (%q,%q)=%q",
a, b, ModeAtMost(a, b), b, a, ModeAtMost(b, a))
}
}
}
}
// 只有 workspace 档需要人 —— 这一条直接决定「找不到人类时怎么办」。
//
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会
// 走到「这条链上有没有人类」这个问题,找不到就是 409。permission.go 里那段
// 「退回第一个管理员」的兜底正因此必须删掉:它让 409 分支永远不可达。
func TestModeNeedsHuman(t *testing.T) {
if ModeNeedsHuman(ModePlan) {
t.Fatal("plan 档不该问人:语义就是这轮不动手,直接拒绝即可")
}
if !ModeNeedsHuman(ModeWorkspace) {
t.Fatal("workspace 档必须问人:越界时需要人点头")
}
if ModeNeedsHuman(ModeFull) {
t.Fatal("full 档不该问人:已声明全权,再问一遍只是噪音")
}
}
func TestModeNeedsHuman_NormalizesInput(t *testing.T) {
// 脏数据走默认档workspace→ 需要人。宁可多问一次,不可静默放行。
if !ModeNeedsHuman("garbage") {
t.Fatal("认不出的档位应当按默认档处理,即需要人")
}
if !ModeNeedsHuman("") {
t.Fatal("空档位应当按默认档处理,即需要人")
}
}
// ─── 强制力 ───
func TestValidEnforcement(t *testing.T) {
if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) {
t.Fatal("native / advisory 都应合法")
}
for _, e := range []string{"", "NATIVE", "none", "enforced"} {
if ValidEnforcement(e) {
t.Fatalf("%q 不该合法", e)
}
}
}
// 保守方向是 advisory没自报过的插件不能替它宣称档位在那里是被强制的。
func TestNormalizeEnforcement_FailsClosed(t *testing.T) {
for _, in := range []string{"", "garbage", "NATIVE", "native "} {
if got := NormalizeEnforcement(in); got != EnforcementAdvisory {
t.Fatalf("NormalizeEnforcement(%q) = %q应当是 advisory", in, got)
}
}
if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative {
t.Fatalf("显式 native 应原样保留,得到 %q", got)
}
}
// PermissionModes 的顺序是 ModeAtMost 的依据,不能被随手改动。
func TestPermissionModesOrder(t *testing.T) {
if len(PermissionModes) != 3 {
t.Fatalf("档位应当是三个,得到 %d 个", len(PermissionModes))
}
if PermissionModes[0] != ModePlan ||
PermissionModes[1] != ModeWorkspace ||
PermissionModes[2] != ModeFull {
t.Fatalf("PermissionModes 必须按宽松程度递增排列plan < workspace < full得到 %v", PermissionModes)
}
}

View File

@ -121,6 +121,21 @@ func Recipients(ctx context.Context, m Mail) {
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
// 会话级权限档位与强制力。
//
// 为什么跑在 payload 外:一封邮件可能推给十几个参与方(收件人 + 拄送),
// 而档位是**会话**的属性,每个人都一样 —— 放进闭包里就是每个参与方
// 查一次库。platformFor 那个坑(会话级的值推给所有人)教过的是反面:
// 会话级与参与方级的字段必须分清楚。档位确实是会话级的。
perm, permErr := repo.GetSessionPermission(ctx, m.SessionID)
if permErr != nil {
// 查不到时给默认档 + advisory不能因为一次查询失败就让插件以为自己拿到了全权。
perm = repo.SessionPermission{
Mode: models.DefaultPermissionMode,
Enforcement: models.EnforcementAdvisory,
}
}
payload := func(role, workspace, forName string) map[string]interface{} {
p := map[string]interface{}{
"mail_id": m.MailID.String(),
@ -160,6 +175,18 @@ func Recipients(ctx context.Context, m Mail) {
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
"from_human": fromHuman,
// permission_mode 声明本任务允许动手到什么程度plan / workspace / full。
//
// 插件必须把它**翻译成平台原生的沙箱/审批配置**,而不是自己按工具名猜着拦:
// 那会同时违反 I-1平台原生信号是唯一真相来源与 I-4插件只搬运不决策
// 而且四个插件对「workspace 到底管什么」必然各猜一套。
"permission_mode": perm.Mode,
// permission_enforcement 是建会话时快照的**事实**native / advisory。
//
// 与 permission_mode 成对下发:前者是要求,后者是对方平台实际做得到。
// 只给前者会让人以为 plan 档把 homeagent 管住了 —— 它的核心没有
// 工具调用拦截点,档位在那里只能写进提示词。
"permission_enforcement": perm.Enforcement,
}
if m.Origin != "" {
p["origin"] = m.Origin

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)

View File

@ -260,6 +260,22 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat
return err
}
// 人建的日程 → 把他设为会话 owner。
//
// 为什么必须设:权限询问的决策人解析是「会话 owner → 线索里最近的人类 → 409」。
// 日历提醒的发件人是 `calendar`(不是人也不是 Agent所以一旦 Agent 在
// 这条会话里要跑需要授权的命令,线索上根本找不到人类 —— 而那个日程
// 就是人自己在界面上设的,他当然是合理的决策人。
//
// 不设的后果删掉管理员兜底之后暴露人建的提醒触发后Agent 的权限询问
// 直接得 409「这条链上没有人类」。
//
// created_by 是 AgentAgent 自己建的日程)时 owner 保持为空 ——
// 那条链上确实没有人类409 是对的。
if u, uErr := repo.GetUserByName(ctx, createdBy); uErr == nil && u != nil {
_ = repo.SetSessionOwner(ctx, sessionID, u.ID)
}
// 发件人固定为 calendar它不是任何一个 Agent也不是人。
// 用创建者的名字会让 Agent 以为人在实时找它,而人此刻可能在睡觉 ——
// 模型据此判断「要不要马上追问」,来源写错会让它问一个不在线的人。