Files
MailUI4Agents/server/internal/models/permission_mode_test.go
JianFeeeee 4050827e5c feat(permission): 新增第三种强制力 partial —— DSH 如实自报,不再冒充 native
# 问题

审计发现 DSH 自报 mode_enforcement=native,而实测它的 Landlock 沙箱受内核 ABI
版本限制、拦截覆盖不完整(PLAN.md L5 自己写的就是 dsh = Landlock partial)。

只有 native / advisory 两个取值时,这个平台无论标哪个都是在说假话:
  - 标 native → 人会以为 plan 档是硬保证,把它当安全边界依赖;
  - 标 advisory → 又低估了它(确实在拦),而「平台无法强制」会让模型
    在本可依赖的边界上过度保守。
多一个取值比多说一句假话便宜。

# 改动

- Go models:EnforcementPartial = "partial",ValidEnforcement 接受它;
  NormalizeEnforcement 对显式自报值一律原样保留(partial 降级到任一极端都是假话),
  未知值仍然 fail-closed 到 advisory。
- 三桥共用 lib/permission-mode.js(逐字节同源):ENFORCE_PARTIAL +
  modeBriefing 三态措辞。partial 版必须同时做到两件事:
  说清「覆盖不完整」,并收回 native 那句「都会被平台拦下」的承诺
  —— 否则模型会以为越界一定被拦,于是不必自己小心。
- 前端 PermissionChip:三个点形区分(实心 / 靶心 / 空心)+ 三套 tooltip 文案;
  认不出的强制力按 advisory(与后端同方向)。
- DSH 插件心跳改报 partial。
- 顺带修正活跃 DSH 会话的历史快照:那批 native 是插件当时的**误报**,
  不是能力变化,因此把 status<>'archived' 的 dsh 会话改为 partial;
  归档会话按设计保留(不重写已结束的历史)。改前已 sqlite3 .backup 备份。

# 验证

- agents.mode_enforcement:dsh 由 native 变为 partial(心跳生效)
- Go 全量、三桥插件 320/362/409、前端 196 全绿(新增 PermissionChip 11 例)
- 三桥共用模块同源校验通过
- 关键判据:partial 的措辞与 native/advisory 两两不同,且不含「无法强制」
2026-09-11 12:04:06 +08:00

167 lines
6.4 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
// 权限档位的判据测试。
//
// 为什么值得单独一组测试:`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) {
for _, ok := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if !ValidEnforcement(ok) {
t.Fatalf("%q 应当合法", ok)
}
}
for _, e := range []string{"", "NATIVE", "none", "enforced", "partial "} {
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)
}
}
// 显式自报的值必须原样保留:partial 是「有拦截点但覆盖不完整」的独立事实,
// 把它降级到任一极端都是在说假话(native 高估、advisory 低估)。
for _, in := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if got := NormalizeEnforcement(in); got != in {
t.Fatalf("显式 %q 应原样保留,得到 %q", in, 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)
}
}