L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
247 lines
10 KiB
JavaScript
247 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_ADVISORY,
|
||
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
|
||
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
|
||
piGuardedTools, piBlocksOutright, modeBriefing,
|
||
} 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', () => {
|
||
assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE);
|
||
assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY);
|
||
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);
|
||
});
|
||
|
||
// ─── opencode ───
|
||
|
||
test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => {
|
||
assert.deepEqual(opencodePermissions(MODE_FULL), []);
|
||
});
|
||
|
||
// 实测结论 3、5、6:edit 覆盖 write/edit/patch;task 会绕过;bash 能重定向写文件
|
||
test('plan 档同时 deny edit / bash / task', () => {
|
||
const rules = opencodePermissions(MODE_PLAN);
|
||
const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission));
|
||
assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch,必须 deny');
|
||
assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny');
|
||
assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny');
|
||
});
|
||
|
||
test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => {
|
||
const rules = opencodePermissions(MODE_PLAN);
|
||
for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) {
|
||
assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`);
|
||
}
|
||
});
|
||
|
||
// 实测结论 1:findLast 胜出 → deny 必须在 allow 之前
|
||
test('workspace 档的 edit 规则 deny 在前 allow 在后(findLast 胜出)', () => {
|
||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||
const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny');
|
||
const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow');
|
||
assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在');
|
||
assert.ok(denyIdx < allowIdx,
|
||
'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny,连允许的路径也被拒');
|
||
});
|
||
|
||
// 实测结论 2:pattern 匹配 worktree 相对路径,绝对路径永远匹配不上
|
||
test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => {
|
||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||
const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow');
|
||
assert.ok(allow, '要有 allow 规则');
|
||
assert.equal(allow.pattern.startsWith('/'), false,
|
||
'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)');
|
||
});
|
||
|
||
// 向更严取整:命令要碰哪些文件解析不出来
|
||
test('workspace 档的 bash 是 ask 而不是 allow(向更严取整)', () => {
|
||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||
const bash = rules.find(r => r.permission === 'bash');
|
||
assert.equal(bash.action, 'ask',
|
||
'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松');
|
||
});
|
||
|
||
test('workspace 档仍然 deny task(子代理带自己的权限跑)', () => {
|
||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||
const task = rules.find(r => r.permission === 'task');
|
||
assert.equal(task.action, 'deny');
|
||
});
|
||
|
||
test('opencode 规则不碰 plan_enter / plan_exit(撞名但语义不同)', () => {
|
||
for (const m of MODES) {
|
||
for (const r of opencodePermissions(m)) {
|
||
assert.notEqual(r.permission, 'plan_enter');
|
||
assert.notEqual(r.permission, 'plan_exit');
|
||
}
|
||
}
|
||
});
|
||
|
||
test('脏档位按默认档下发(与 workspace 相同)', () => {
|
||
assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE));
|
||
});
|
||
|
||
// ─── 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 处理');
|
||
});
|