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

@ -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 收到明确的错误信息,由它自己决定下一步: