Files
MailUI4Agents/plugins/dsh-mail-bridge/test/permission-mode.test.mjs
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

247 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 < fullmodeAtMost 的依据)', () => {
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、6edit 覆盖 write/edit/patchtask 会绕过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} 不该被禁`);
}
});
// 实测结论 1findLast 胜出 → 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连允许的路径也被拒');
});
// 实测结论 2pattern 匹配 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 处理');
});