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

@ -3,17 +3,22 @@
*
* 两个字段必须成对显示:
* permission_mode 档位plan / workspace / full——「要求什么」
* permission_enforcement 强制力native / advisory——「平台实际做到了什么」
* permission_enforcement 强制力native / partial / advisory——「平台实际做到了什么」
*
* 为什么强制力也要上界面:只显示档位会让人以为 plan 档管住了 homeagent
* 而 homeagent 没有工具拦截点、档位只是提示词建议advisory
* 差异可见才符合 I-5失败必须当场可见
*
* 为什么必须有三态而不是两态DSH 有真沙箱,但 Landlock 受内核 ABI 限制、
* 拦截覆盖不完整。把这种平台标成 native 是**高估**(人会把它当硬保证),
* 标成 advisory 是**低估**(它确实在拦,「平台不强制」是假话)。
* 三个点形(实心 / 靶心 / 空心)让差异在列表里一眼可见。
*/
export interface PermissionChipProps {
/** 档位plan / workspace / full */
mode?: string;
/** 实际强制力native / advisory */
/** 实际强制力native / partial / advisory */
enforcement?: string;
/** 紧凑模式(卡片上用);默认常规(详情页用) */
compact?: boolean;
@ -25,21 +30,39 @@ const MODE_LABEL: Record<string, string> = {
full: '全权',
};
/** 档位 → 文字说明tooltip 用) */
/** 强制力取值 → 中文短标签tooltip 用) */
export const ENFORCEMENT_LABEL: Record<string, string> = {
native: '平台强制',
partial: '平台强制(覆盖不完整)',
advisory: '仅提示',
};
/** 归一化强制力:认不出的值按 advisory保守方向与后端 Normalize 同语义) */
function normalizeEnforcement(e?: string): string {
return e === 'native' || e === 'partial' || e === 'advisory' ? e : 'advisory';
}
/** 档位 → 文字说明tooltip 用)。三种强制力必须给出三种话。 */
export function permissionModeHint(mode?: string, enforcement?: string): string {
const enforced = enforcement === 'native';
const e = normalizeEnforcement(enforcement);
switch (mode) {
case 'plan':
return enforced
? 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。'
: 'plan 档:只读advisory平台不强制。请把结论写在回信里。';
if (e === 'native') return 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。';
if (e === 'partial') {
return 'plan 档:只读。平台会拦截写/改/执行,但覆盖不完整(沙箱能力受限,有已知缺口)——'
+ '不要把「会被拦下」当保证,请把结论写在回信里。';
}
return 'plan 档只读advisory平台不强制。请把结论写在回信里。';
case 'full':
return 'full 档:全权。工具调用不需额外授权。';
case 'workspace':
default:
return enforced
? 'workspace 档:目录内可动,越界需经授权。'
: 'workspace 档advisory平台不强制。请把改动限制在工作目录内。';
if (e === 'native') return 'workspace 档:目录内可动,越界需经授权。';
if (e === 'partial') {
return 'workspace 档:目录内可动,越界会请求授权 —— 但沙箱覆盖不完整(有已知缺口),'
+ '请主动把改动限制在工作目录内。';
}
return 'workspace 档advisory平台不强制。请把改动限制在工作目录内。';
}
}
@ -48,9 +71,9 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
const normalized = mode || '';
if (!['plan', 'workspace', 'full'].includes(normalized)) return null;
const enforced = enforcement === 'native';
const e = normalizeEnforcement(enforcement);
// 配色按档位plan 用蓝只读workspace 用有边界的动full 用红/橙(全权)
// 配色按档位plan 用蓝只读workspace 用绿有边界的动full 用橙(全权)
const color = normalized === 'plan'
? 'bg-blue-50 text-blue-700 border-blue-200'
: normalized === 'full'
@ -62,18 +85,26 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
return (
<span
title={hint + (enforcement ? `(强制力:${enforcement === 'native' ? '平台强制' : '仅提示' }` : '')}
title={`${hint}(强制力:${ENFORCEMENT_LABEL[e]}`}
className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${
compact ? 'px-1 text-[10px]' : 'px-1.5 text-xs'
}`}
>
{label}
{enforced ? (
// native平台强制 —— 实心圆点
{e === 'native' ? (
// native完整强制 —— 实心圆点
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" />
) : e === 'partial' ? (
// partial有拦截但覆盖不完整 —— 环内实心(靶心),与实心/空心都不同形
<span
data-testid="chip-partial"
className="inline-flex items-center justify-center w-1.5 h-1.5 rounded-full border border-current"
>
<span className="block w-[3px] h-[3px] rounded-full bg-current" />
</span>
) : (
// advisory仅提示 —— 空心圆点
<span className="inline-block w-1.5 h-1.5 rounded-full border border-current" />
<span data-testid="chip-advisory" className="inline-block w-1.5 h-1.5 rounded-full border border-current" />
)}
</span>
);

View File

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import PermissionChip, {
permissionModeHint,
ENFORCEMENT_LABEL,
} from '../../src/components/PermissionChip';
/**
* 权限档位徽标。
*
* 判据集中在「三个强制力必须可区分」——因为把它显示错的代价不对称:
* - 高估partial 显示成 native人以为档位是硬保证把它当安全边界依赖
* - 低估partial 显示成 advisory人以为平台完全不拦在能依赖的边界上过度保守
* 这张徽标是这两个事实唯一的对外呈现,所以点形与文案都必须区分。
*/
describe('PermissionChip 档位显示', () => {
it('档位为空或非法时不渲染(人→人的信、旧会话)', () => {
const { container: c1 } = render(React.createElement(PermissionChip, {}));
expect(c1.firstChild).toBeNull();
const { container: c2 } = render(React.createElement(PermissionChip, { mode: 'nonsense' }));
expect(c2.firstChild).toBeNull();
});
it('三个档位各有中文短标签', () => {
for (const [mode, label] of [['plan', '只读'], ['workspace', '目录内'], ['full', '全权']]) {
const { unmount } = render(
React.createElement(PermissionChip, { mode, enforcement: 'native' })
);
expect(screen.getByText(label)).toBeInTheDocument();
unmount();
}
});
});
describe('PermissionChip 强制力三态', () => {
it('native 用实心点,不带 partial/advisory 的标记', () => {
render(React.createElement(PermissionChip, { mode: 'plan', enforcement: 'native' }));
expect(screen.queryByTestId('chip-partial')).toBeNull();
expect(screen.queryByTestId('chip-advisory')).toBeNull();
});
it('partial 渲染为靶心(与实心、空心都不同形)', () => {
render(React.createElement(PermissionChip, { mode: 'plan', enforcement: 'partial' }));
expect(screen.getByTestId('chip-partial')).toBeInTheDocument();
expect(screen.queryByTestId('chip-advisory')).toBeNull();
});
it('advisory 渲染为空心点', () => {
render(React.createElement(PermissionChip, { mode: 'plan', enforcement: 'advisory' }));
expect(screen.getByTestId('chip-advisory')).toBeInTheDocument();
expect(screen.queryByTestId('chip-partial')).toBeNull();
});
it('认不出的强制力按 advisory 处理(保守方向,与后端 Normalize 同语义)', () => {
render(React.createElement(PermissionChip, { mode: 'plan', enforcement: 'PARTIAL' }));
expect(screen.getByTestId('chip-advisory')).toBeInTheDocument();
expect(screen.queryByTestId('chip-partial')).toBeNull();
});
it('tooltip 里给出强制力的中文标签', () => {
render(React.createElement(PermissionChip, { mode: 'workspace', enforcement: 'partial' }));
const chip = screen.getByText('目录内').closest('span');
expect(chip?.getAttribute('title')).toContain(ENFORCEMENT_LABEL.partial);
});
});
describe('permissionModeHint 三态文案', () => {
it('三种强制力的说明两两不同', () => {
for (const mode of ['plan', 'workspace']) {
const nat = permissionModeHint(mode, 'native');
const par = permissionModeHint(mode, 'partial');
const adv = permissionModeHint(mode, 'advisory');
expect(par).not.toBe(nat);
expect(par).not.toBe(adv);
}
});
it('partial 必须说明覆盖不完整,且不能说「平台不强制」', () => {
for (const mode of ['plan', 'workspace']) {
const par = permissionModeHint(mode, 'partial');
// 「不完整」或「缺口」必须出现:否则人无法知道拦截不可完全依赖
expect(par).toMatch(/不完整|缺口/);
// 「平台不强制」是 advisory 的说法,对 partial 是假话
expect(par).not.toMatch(/平台不强制/);
}
});
it('advisory 仍明说平台不强制', () => {
expect(permissionModeHint('plan', 'advisory')).toMatch(/平台不强制/);
expect(permissionModeHint('workspace', 'advisory')).toMatch(/平台不强制/);
});
it('full 档不提强制力差异(全权档本来就不需要授权)', () => {
for (const e of ['native', 'partial', 'advisory']) {
expect(permissionModeHint('full', e)).toMatch(/全权/);
}
});
});

View File

@ -5,6 +5,7 @@ export const MODES: string[];
export const DEFAULT_MODE: string;
export const ENFORCE_NATIVE: 'native';
export const ENFORCE_PARTIAL: 'partial';
export const ENFORCE_ADVISORY: 'advisory';
export function normalizeMode(mode: unknown): string;

View File

@ -34,8 +34,16 @@ export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
export const DEFAULT_MODE = MODE_WORKSPACE;
/** 平台有原生拦截点,档位被真正执行。 */
/** 平台有原生拦截点,档位被完整执行。 */
export const ENFORCE_NATIVE = 'native';
/**
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
*
* 实测例子DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
*/
export const ENFORCE_PARTIAL = 'partial';
/** 平台没有拦截点,档位只写进提示词。 */
export const ENFORCE_ADVISORY = 'advisory';
@ -58,11 +66,16 @@ export function normalizeMode(mode) {
* 保守方向是 advisory 而不是 native不能替一个没自报过的平台宣称
* 「档位在这里是被强制的」。
*
* 但**显式自报的值一律原样保留**(含 partial那是平台自己的事实陈述
* 把它降级到任一极端都是在替它说假话。
*
* @param {unknown} e
* @returns {string}
*/
export function normalizeEnforcement(e) {
return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY;
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY
? e
: ENFORCE_ADVISORY;
}
/**
@ -232,16 +245,19 @@ export function piBlocksOutright(mode) {
/**
* 给模型看的档位说明,放进提示词。
*
* 为什么 advisory 时措辞完全不同那种平台homeagent没有任何机制阻止
* 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。
* 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。
* 三种强制力必须说三种话:
* - native :「会被拦下」—— 模型可以依赖它
* - partial「大部分会被拦下但有已知缺口」—— 不能依赖它
* - advisory「平台不拦靠你自己遵守」
* 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心;
* 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。
*
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
* @returns {string}
*/
export function modeBriefing({ mode, enforcement, workspace }) {
const m = normalizeMode(mode);
const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE;
const e = normalizeEnforcement(enforcement);
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
if (m === MODE_FULL) {
@ -249,26 +265,42 @@ export function modeBriefing({ mode, enforcement, workspace }) {
}
if (m === MODE_PLAN) {
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
'本任务权限档位plan只读。',
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n')
: [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
'本任务权限档位plan只读。',
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
'需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
`本任务权限档位workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
].join('\n')
: [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
].join('\n');
}
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}

View File

@ -443,9 +443,15 @@ export function apply(ctx: any, config: PluginConfig): void {
async function beat(): Promise<void> {
const body: Record<string, unknown> = {};
// DSH 有真沙箱read-only / workspace-write / danger-full-access
// 档位在这里是被强制执行的,不是 advisory。
body.mode_enforcement = 'native';
// DSH 有真沙箱read-only / workspace-write / danger-full-access但它对
// 档位的拦截**覆盖不完整**Landlock 受内核 ABI 版本限制,部分路径与系统
// 调用未被约束PLAN.md L5 实测结论dsh = Landlock partial
//
// 因此报 partial 而不是 native。两个极端都是在说假话
// - native人会以为 plan 档是硬保证,把它当安全边界依赖;
// - advisory又低估了它确实在拦而「平台无法强制」会让模型
// 在本可依赖的边界上过度保守。
body.mode_enforcement = 'partial';
const [entries, models] = await Promise.all([collectSessions(), collectModels()]);
if (entries) {
body.platform_sessions = snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id));

View File

@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_ADVISORY,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
@ -30,8 +30,9 @@ test('非法档位 fail-closed 到默认档,不是 full', () => {
});
test('强制力保守方向是 advisory', () => {
assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE);
assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY);
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
}
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
}
@ -244,3 +245,39 @@ test('脏输入不炸且按默认档', () => {
assert.match(s, /workspace/);
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
});
// ─── partial有拦截点但覆盖不完整 ───
//
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
}
});
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
assert.equal(/无法强制/.test(par), false,
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
}
});
test('partial 不能把「会被拦下」当成保证', () => {
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
// native 版说「都会被平台拦下」partial 版必须收回这个承诺
assert.equal(/都会被平台拦下/.test(plan), false,
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
});
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/);
});

View File

@ -34,8 +34,16 @@ export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
export const DEFAULT_MODE = MODE_WORKSPACE;
/** 平台有原生拦截点,档位被真正执行。 */
/** 平台有原生拦截点,档位被完整执行。 */
export const ENFORCE_NATIVE = 'native';
/**
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
*
* 实测例子DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
*/
export const ENFORCE_PARTIAL = 'partial';
/** 平台没有拦截点,档位只写进提示词。 */
export const ENFORCE_ADVISORY = 'advisory';
@ -58,11 +66,16 @@ export function normalizeMode(mode) {
* 保守方向是 advisory 而不是 native不能替一个没自报过的平台宣称
* 「档位在这里是被强制的」。
*
* 但**显式自报的值一律原样保留**(含 partial那是平台自己的事实陈述
* 把它降级到任一极端都是在替它说假话。
*
* @param {unknown} e
* @returns {string}
*/
export function normalizeEnforcement(e) {
return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY;
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY
? e
: ENFORCE_ADVISORY;
}
/**
@ -232,16 +245,19 @@ export function piBlocksOutright(mode) {
/**
* 给模型看的档位说明,放进提示词。
*
* 为什么 advisory 时措辞完全不同那种平台homeagent没有任何机制阻止
* 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。
* 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。
* 三种强制力必须说三种话:
* - native :「会被拦下」—— 模型可以依赖它
* - partial「大部分会被拦下但有已知缺口」—— 不能依赖它
* - advisory「平台不拦靠你自己遵守」
* 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心;
* 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。
*
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
* @returns {string}
*/
export function modeBriefing({ mode, enforcement, workspace }) {
const m = normalizeMode(mode);
const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE;
const e = normalizeEnforcement(enforcement);
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
if (m === MODE_FULL) {
@ -249,26 +265,42 @@ export function modeBriefing({ mode, enforcement, workspace }) {
}
if (m === MODE_PLAN) {
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
'本任务权限档位plan只读。',
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n')
: [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
'本任务权限档位plan只读。',
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
'需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
`本任务权限档位workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
].join('\n')
: [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
].join('\n');
}
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}

View File

@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_ADVISORY,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
@ -30,8 +30,9 @@ test('非法档位 fail-closed 到默认档,不是 full', () => {
});
test('强制力保守方向是 advisory', () => {
assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE);
assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY);
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
}
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
}
@ -244,3 +245,39 @@ test('脏输入不炸且按默认档', () => {
assert.match(s, /workspace/);
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
});
// ─── partial有拦截点但覆盖不完整 ───
//
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
}
});
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
assert.equal(/无法强制/.test(par), false,
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
}
});
test('partial 不能把「会被拦下」当成保证', () => {
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
// native 版说「都会被平台拦下」partial 版必须收回这个承诺
assert.equal(/都会被平台拦下/.test(plan), false,
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
});
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/);
});

View File

@ -34,8 +34,16 @@ export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
export const DEFAULT_MODE = MODE_WORKSPACE;
/** 平台有原生拦截点,档位被真正执行。 */
/** 平台有原生拦截点,档位被完整执行。 */
export const ENFORCE_NATIVE = 'native';
/**
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
*
* 实测例子DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
*/
export const ENFORCE_PARTIAL = 'partial';
/** 平台没有拦截点,档位只写进提示词。 */
export const ENFORCE_ADVISORY = 'advisory';
@ -58,11 +66,16 @@ export function normalizeMode(mode) {
* 保守方向是 advisory 而不是 native不能替一个没自报过的平台宣称
* 「档位在这里是被强制的」。
*
* 但**显式自报的值一律原样保留**(含 partial那是平台自己的事实陈述
* 把它降级到任一极端都是在替它说假话。
*
* @param {unknown} e
* @returns {string}
*/
export function normalizeEnforcement(e) {
return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY;
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY
? e
: ENFORCE_ADVISORY;
}
/**
@ -232,16 +245,19 @@ export function piBlocksOutright(mode) {
/**
* 给模型看的档位说明,放进提示词。
*
* 为什么 advisory 时措辞完全不同那种平台homeagent没有任何机制阻止
* 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。
* 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。
* 三种强制力必须说三种话:
* - native :「会被拦下」—— 模型可以依赖它
* - partial「大部分会被拦下但有已知缺口」—— 不能依赖它
* - advisory「平台不拦靠你自己遵守」
* 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心;
* 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。
*
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
* @returns {string}
*/
export function modeBriefing({ mode, enforcement, workspace }) {
const m = normalizeMode(mode);
const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE;
const e = normalizeEnforcement(enforcement);
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
if (m === MODE_FULL) {
@ -249,26 +265,42 @@ export function modeBriefing({ mode, enforcement, workspace }) {
}
if (m === MODE_PLAN) {
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
'本任务权限档位plan只读。',
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n')
: [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
'本任务权限档位plan只读。',
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
'需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return enforced
? [
if (e === ENFORCE_NATIVE) {
return [
`本任务权限档位workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
].join('\n')
: [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
].join('\n');
}
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}

View File

@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_ADVISORY,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
@ -30,8 +30,9 @@ test('非法档位 fail-closed 到默认档,不是 full', () => {
});
test('强制力保守方向是 advisory', () => {
assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE);
assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY);
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
}
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
}
@ -244,3 +245,39 @@ test('脏输入不炸且按默认档', () => {
assert.match(s, /workspace/);
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
});
// ─── partial有拦截点但覆盖不完整 ───
//
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
}
});
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
assert.equal(/无法强制/.test(par), false,
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
}
});
test('partial 不能把「会被拦下」当成保证', () => {
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
// native 版说「都会被平台拦下」partial 版必须收回这个承诺
assert.equal(/都会被平台拦下/.test(plan), false,
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
});
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/);
});

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)
}
}
}