Files
MailUI4Agents/server/internal/models/permission_mode.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

154 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
// ─── 权限档位 ───
//
// 三档描述「这条任务允许 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"
// EnforcementPartial 平台有原生拦截点,但覆盖不完整。
//
// 为什么需要这个中间值:实测 DSH 的 Landlock 沙箱受内核 ABI 版本限制,
// read-only / workspace-write 能拦下大部分写与命令执行,但有已知缺口。
//
// 只给 native / advisory 两个取值会逼出一个二选一的假陈述:
// - 标 native → 人以为档位被完整强制,于是把 plan 档当成硬保证;
// - 标 advisory → 反过来低估(它确实在拦),"无法强制" 的说法是错的。
// 两边都在骗人。多一个取值比多说一句假话便宜。
EnforcementPartial = "partial"
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
//
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
EnforcementAdvisory = "advisory"
)
// ValidEnforcement 判断强制力取值是否合法。
func ValidEnforcement(e string) bool {
return e == EnforcementNative || e == EnforcementPartial || e == EnforcementAdvisory
}
// NormalizeEnforcement 收敛强制力取值。
//
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
func NormalizeEnforcement(e string) string {
if ValidEnforcement(e) {
return e
}
return EnforcementAdvisory
}