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:
@ -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 向人类发起的权限请求
|
||||
|
||||
142
gateway/internal/models/permission_mode.go
Normal file
142
gateway/internal/models/permission_mode.go
Normal 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 0(plan),
|
||||
// 而 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
|
||||
}
|
||||
160
gateway/internal/models/permission_mode_test.go
Normal file
160
gateway/internal/models/permission_mode_test.go
Normal 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) = %q,want %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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user