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 两两不同,且不含「无法强制」
This commit is contained in:
2026-09-11 12:04:06 +08:00
parent 429149e118
commit 4050827e5c
12 changed files with 455 additions and 92 deletions

View File

@ -115,9 +115,20 @@ func ModeNeedsHuman(m string) bool {
// 都对人可见I-5失败必须可见—— 否则发件人以为 plan 档管住了 homeagent
// 而 homeagent 的核心根本没有工具调用拦截点。
const (
// EnforcementNative 平台有原生拦截点,档位被真正执行。
// EnforcementNative 平台有原生拦截点,档位被完整执行。
EnforcementNative = "native"
// EnforcementPartial 平台有原生拦截点,但覆盖不完整。
//
// 为什么需要这个中间值:实测 DSH 的 Landlock 沙箱受内核 ABI 版本限制,
// read-only / workspace-write 能拦下大部分写与命令执行,但有已知缺口。
//
// 只给 native / advisory 两个取值会逼出一个二选一的假陈述:
// - 标 native → 人以为档位被完整强制,于是把 plan 档当成硬保证;
// - 标 advisory → 反过来低估(它确实在拦),"无法强制" 的说法是错的。
// 两边都在骗人。多一个取值比多说一句假话便宜。
EnforcementPartial = "partial"
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
//
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
@ -127,7 +138,7 @@ const (
// ValidEnforcement 判断强制力取值是否合法。
func ValidEnforcement(e string) bool {
return e == EnforcementNative || e == EnforcementAdvisory
return e == EnforcementNative || e == EnforcementPartial || e == EnforcementAdvisory
}
// NormalizeEnforcement 收敛强制力取值。

View File

@ -125,10 +125,12 @@ func TestModeNeedsHuman_NormalizesInput(t *testing.T) {
// ─── 强制力 ───
func TestValidEnforcement(t *testing.T) {
if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) {
t.Fatal("native / advisory 都应合法")
for _, ok := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if !ValidEnforcement(ok) {
t.Fatalf("%q 应当合法", ok)
}
}
for _, e := range []string{"", "NATIVE", "none", "enforced"} {
for _, e := range []string{"", "NATIVE", "none", "enforced", "partial "} {
if ValidEnforcement(e) {
t.Fatalf("%q 不该合法", e)
}
@ -142,8 +144,12 @@ func TestNormalizeEnforcement_FailsClosed(t *testing.T) {
t.Fatalf("NormalizeEnforcement(%q) = %q应当是 advisory", in, got)
}
}
if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative {
t.Fatalf("显式 native 应原样保留,得到 %q", got)
// 显式自报的值必须原样保留partial 是「有拦截点但覆盖不完整」的独立事实,
// 把它降级到任一极端都是在说假话native 高估、advisory 低估)。
for _, in := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if got := NormalizeEnforcement(in); got != in {
t.Fatalf("显式 %q 应原样保留,得到 %q", in, got)
}
}
}