Files
MailUI4Agents/gateway/internal/models/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

143 lines
5.8 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 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
}