线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了 workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮 工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。 四件事: 1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null` = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。" 同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。 2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** —— 所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径: 已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full, plan 与"链上没有人类"照旧 fail closed。 3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js` 都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改" —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条 **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有 「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。 4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的 (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑 `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。 共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。 另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`, 判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。 判据证据(每条都做过"能不能红"的变异): - 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红; - zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。 各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc); electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0; Go `go test ./...` 全 ok。
238 lines
10 KiB
JavaScript
238 lines
10 KiB
JavaScript
/**
|
||
* lib/permission-mode.js 的测试 —— 四个平台逐字节共用。
|
||
*
|
||
* 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但
|
||
* 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import {
|
||
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
|
||
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
|
||
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
|
||
dshSandboxMode, dshApprovalPolicy,
|
||
piGuardedTools, piBlocksOutright, modeBriefing,
|
||
modeForStateWrite,
|
||
} from '../lib/permission-mode.js';
|
||
|
||
// ─── 归一化 ───
|
||
|
||
test('合法档位原样返回', () => {
|
||
for (const m of MODES) assert.equal(normalizeMode(m), m);
|
||
});
|
||
|
||
test('非法档位 fail-closed 到默认档,不是 full', () => {
|
||
for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) {
|
||
assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`);
|
||
}
|
||
assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full');
|
||
});
|
||
|
||
test('强制力保守方向是 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);
|
||
}
|
||
});
|
||
|
||
test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => {
|
||
assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]);
|
||
});
|
||
|
||
// ─── modeAtMost ───
|
||
|
||
test('modeAtMost 取更严的一档', () => {
|
||
assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN);
|
||
assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN);
|
||
assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE);
|
||
assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL);
|
||
});
|
||
|
||
// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换,
|
||
// 单元测试当场抓到。两边保持同一套语义。
|
||
test('modeAtMost 可交换(脏值也不例外)', () => {
|
||
const all = [...MODES, 'garbage', '', null];
|
||
for (const a of all) {
|
||
for (const b of all) {
|
||
assert.equal(modeAtMost(a, b), modeAtMost(b, a),
|
||
`不可交换:(${a},${b})`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('脏值不得把 plan 抬成更宽松的档', () => {
|
||
assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN);
|
||
});
|
||
|
||
// ─── modeNeedsHuman ───
|
||
|
||
test('只有 workspace 档需要人点头', () => {
|
||
assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人');
|
||
assert.equal(modeNeedsHuman(MODE_WORKSPACE), true);
|
||
assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人');
|
||
});
|
||
|
||
test('脏档位按默认档处理,即需要人(宁可多问一次)', () => {
|
||
assert.equal(modeNeedsHuman('garbage'), true);
|
||
assert.equal(modeNeedsHuman(''), true);
|
||
});
|
||
|
||
// ─── DSH ───
|
||
|
||
test('DSH 三档与原生沙箱一一对应', () => {
|
||
assert.equal(dshSandboxMode(MODE_PLAN), 'read-only');
|
||
assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write');
|
||
assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access');
|
||
});
|
||
|
||
// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall
|
||
// 之前短路 return "rejected",approval/request 钩子根本不触发。
|
||
test('DSH 审批策略只在 workspace 档是 ask', () => {
|
||
assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask');
|
||
assert.equal(dshApprovalPolicy(MODE_PLAN), 'never');
|
||
assert.equal(dshApprovalPolicy(MODE_FULL), 'never');
|
||
});
|
||
|
||
test('DSH 脏档位按默认档(workspace-write + ask)', () => {
|
||
assert.equal(dshSandboxMode('garbage'), 'workspace-write');
|
||
assert.equal(dshApprovalPolicy('garbage'), 'ask');
|
||
});
|
||
|
||
// ─── pi ───
|
||
|
||
test('pi 在 full 档不守卫任何工具', () => {
|
||
assert.deepEqual(piGuardedTools(MODE_FULL), []);
|
||
});
|
||
|
||
test('pi 在 plan / workspace 档守卫 bash / write / edit', () => {
|
||
for (const m of [MODE_PLAN, MODE_WORKSPACE]) {
|
||
const g = piGuardedTools(m);
|
||
assert.ok(g.includes('bash'));
|
||
assert.ok(g.includes('write'));
|
||
assert.ok(g.includes('edit'));
|
||
}
|
||
});
|
||
|
||
test('pi 不守卫读类工具', () => {
|
||
const g = piGuardedTools(MODE_WORKSPACE);
|
||
for (const t of ['read', 'grep', 'find', 'ls']) {
|
||
assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`);
|
||
}
|
||
});
|
||
|
||
test('pi 在 plan 档直接拒绝,不走问人流程', () => {
|
||
assert.equal(piBlocksOutright(MODE_PLAN), true);
|
||
assert.equal(piBlocksOutright(MODE_WORKSPACE), false);
|
||
assert.equal(piBlocksOutright(MODE_FULL), false);
|
||
});
|
||
|
||
// ─── modeBriefing ───
|
||
|
||
test('full 档的说明不提授权', () => {
|
||
const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE });
|
||
assert.match(s, /full/);
|
||
assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false);
|
||
});
|
||
|
||
// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为
|
||
// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。
|
||
test('advisory 必须明说平台无法强制这一档', () => {
|
||
const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY });
|
||
const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE });
|
||
assert.match(adv, /无法强制/);
|
||
assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制');
|
||
assert.notEqual(adv, nat, '两种强制力的措辞必须不同');
|
||
});
|
||
|
||
test('workspace 档的 advisory 版同样明说', () => {
|
||
const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' });
|
||
assert.match(adv, /无法强制/);
|
||
assert.match(adv, /\/tmp\/x/, '要带上具体目录');
|
||
});
|
||
|
||
test('native 的 workspace 说明要交代「授权可能被拒」', () => {
|
||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' });
|
||
assert.match(s, /\/srv\/app/);
|
||
assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试');
|
||
});
|
||
|
||
test('plan 档的说明必须告诉模型「把方案写在回信里」', () => {
|
||
for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) {
|
||
const s = modeBriefing({ mode: MODE_PLAN, enforcement: e });
|
||
assert.match(s, /回信/, '不给出路的话模型只会反复撞墙');
|
||
}
|
||
});
|
||
|
||
test('缺 workspace 时用兜底措辞,不出现 undefined', () => {
|
||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE });
|
||
assert.equal(/undefined/.test(s), false);
|
||
assert.equal(/`` /.test(s), false);
|
||
});
|
||
|
||
test('脏输入不炸且按默认档', () => {
|
||
const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' });
|
||
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, /回信/);
|
||
});
|
||
|
||
// ─── 状态写入点的档位解析(2026-09-14 线上事故的形状)───
|
||
|
||
test('★ 状态写入点:缺字段 → null(不写),不是默认档', () => {
|
||
/*
|
||
* 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档,
|
||
* 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次"
|
||
* 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。
|
||
*
|
||
* 规则:**默认值可以出现在决策里,不可以出现在状态写入里。**
|
||
*/
|
||
for (const missing of ['', ' ', undefined, null, 42, {}]) {
|
||
assert.equal(modeForStateWrite(missing), null,
|
||
`${JSON.stringify(missing)} 应当返回 null(= 不要写状态),而不是兜一个档`);
|
||
}
|
||
// 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义)
|
||
assert.equal(modeForStateWrite('full'), 'full');
|
||
assert.equal(modeForStateWrite(' workspace '), 'workspace');
|
||
assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"');
|
||
assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故');
|
||
});
|