diff --git a/client/electron/src/components/PermissionChip.tsx b/client/electron/src/components/PermissionChip.tsx index 9351752..0076c8c 100644 --- a/client/electron/src/components/PermissionChip.tsx +++ b/client/electron/src/components/PermissionChip.tsx @@ -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 = { full: '全权', }; -/** 档位 → 文字说明(tooltip 用) */ +/** 强制力取值 → 中文短标签(tooltip 用) */ +export const ENFORCEMENT_LABEL: Record = { + 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 ( {label} - {enforced ? ( - // native:平台强制 —— 实心圆点 + {e === 'native' ? ( + // native:完整强制 —— 实心圆点 + ) : e === 'partial' ? ( + // partial:有拦截但覆盖不完整 —— 环内实心(靶心),与实心/空心都不同形 + + + ) : ( // advisory:仅提示 —— 空心圆点 - + )} ); diff --git a/client/electron/test/components/PermissionChip.test.tsx b/client/electron/test/components/PermissionChip.test.tsx new file mode 100644 index 0000000..69e409c --- /dev/null +++ b/client/electron/test/components/PermissionChip.test.tsx @@ -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(/全权/); + } + }); +}); diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts index 23252fe..2c587e3 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts +++ b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts @@ -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; diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.js b/plugins/dsh-mail-bridge/lib/permission-mode.js index 4a0dcd4..cb5656c 100644 --- a/plugins/dsh-mail-bridge/lib/permission-mode.js +++ b/plugins/dsh-mail-bridge/lib/permission-mode.js @@ -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'); } diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 31a0532..bd19c67 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -443,9 +443,15 @@ export function apply(ctx: any, config: PluginConfig): void { async function beat(): Promise { const body: Record = {}; - // 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)); diff --git a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs index dada93d..f4826cb 100644 --- a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs @@ -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, /回信/); +}); diff --git a/plugins/opencode-mail-bridge/lib/permission-mode.js b/plugins/opencode-mail-bridge/lib/permission-mode.js index 4a0dcd4..cb5656c 100644 --- a/plugins/opencode-mail-bridge/lib/permission-mode.js +++ b/plugins/opencode-mail-bridge/lib/permission-mode.js @@ -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'); } diff --git a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs index dada93d..f4826cb 100644 --- a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs @@ -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, /回信/); +}); diff --git a/plugins/pi-mail-bridge/lib/permission-mode.js b/plugins/pi-mail-bridge/lib/permission-mode.js index 4a0dcd4..cb5656c 100644 --- a/plugins/pi-mail-bridge/lib/permission-mode.js +++ b/plugins/pi-mail-bridge/lib/permission-mode.js @@ -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'); } diff --git a/plugins/pi-mail-bridge/test/permission-mode.test.mjs b/plugins/pi-mail-bridge/test/permission-mode.test.mjs index dada93d..f4826cb 100644 --- a/plugins/pi-mail-bridge/test/permission-mode.test.mjs +++ b/plugins/pi-mail-bridge/test/permission-mode.test.mjs @@ -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, /回信/); +}); diff --git a/server/internal/models/permission_mode.go b/server/internal/models/permission_mode.go index d6f766d..cff5003 100644 --- a/server/internal/models/permission_mode.go +++ b/server/internal/models/permission_mode.go @@ -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 收敛强制力取值。 diff --git a/server/internal/models/permission_mode_test.go b/server/internal/models/permission_mode_test.go index a32afb8..982bd19 100644 --- a/server/internal/models/permission_mode_test.go +++ b/server/internal/models/permission_mode_test.go @@ -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) + } } }