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。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -0,0 +1,33 @@
export const MODE_PLAN: 'plan';
export const MODE_WORKSPACE: 'workspace';
export const MODE_FULL: 'full';
export const MODES: string[];
export const DEFAULT_MODE: string;
export const ENFORCE_NATIVE: 'native';
export const ENFORCE_ADVISORY: 'advisory';
export function normalizeMode(mode: unknown): string;
export function normalizeEnforcement(e: unknown): string;
export function modeAtMost(a: string, b: string): string;
export function modeNeedsHuman(mode: string): boolean;
export interface OpencodePermissionRule {
permission: string;
action: string;
pattern: string;
}
export function opencodePermissions(mode: string): OpencodePermissionRule[];
export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access';
export function dshApprovalPolicy(mode: string): 'ask' | 'never';
export function piGuardedTools(mode: string): string[];
export function piBlocksOutright(mode: string): boolean;
export interface ModeBriefingCtx {
mode: string;
enforcement: string;
workspace?: string;
}
export function modeBriefing(ctx: ModeBriefingCtx): string;

View File

@ -0,0 +1,274 @@
/**
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
*
* ## 分工
*
* **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的
* 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。
*
* 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1平台原生信号是唯一
* 真相来源)与 I-4插件只搬运不决策而且四个插件对「workspace 到底管
* 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。
*
* ## 为什么必须「向更严取整」
*
* 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么
* native / advisory。pi 就是例子write/edit 能查 `input.path` 判断越界,
* 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能
* 「每条 bash 都问人」,比声明的更严。
*
* 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效
* (人以为收紧了,实际没有)。
*/
/** 只读:查资料、读代码、出方案,一个字都不许写。 */
export const MODE_PLAN = 'plan';
/** 本目录内可动手,越界要问人。默认档。 */
export const MODE_WORKSPACE = 'workspace';
/** 自动放行,不问人。 */
export const MODE_FULL = 'full';
/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */
export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
export const DEFAULT_MODE = MODE_WORKSPACE;
/** 平台有原生拦截点,档位被真正执行。 */
export const ENFORCE_NATIVE = 'native';
/** 平台没有拦截点,档位只写进提示词。 */
export const ENFORCE_ADVISORY = 'advisory';
/**
* 把外部输入收敛成合法档位。
*
* 非法值 → 默认档(**不是** full。拼错一个档位名不该换来更大的权限。
* 与 Gateway 的 NormalizePermissionMode 同语义。
*
* @param {unknown} mode
* @returns {string}
*/
export function normalizeMode(mode) {
return MODES.includes(mode) ? mode : DEFAULT_MODE;
}
/**
* 收敛强制力取值。空串或非法值 → advisory。
*
* 保守方向是 advisory 而不是 native不能替一个没自报过的平台宣称
* 「档位在这里是被强制的」。
*
* @param {unknown} e
* @returns {string}
*/
export function normalizeEnforcement(e) {
return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY;
}
/**
* 取两个档位里更严的那一个。
*
* 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。
* Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它
* 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。
*
* @param {string} a
* @param {string} b
* @returns {string}
*/
export function modeAtMost(a, b) {
const na = normalizeMode(a);
const nb = normalizeMode(b);
return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb;
}
/**
* 这一档会不会产生权限邮件(即需不需要人来点头)。
*
* 只有 workspace 档需要人plan 档当场拒绝、full 档自动放行,两者都不问人。
* 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。
*
* @param {string} mode
* @returns {boolean}
*/
export function modeNeedsHuman(mode) {
return normalizeMode(mode) === MODE_WORKSPACE;
}
/**
* opencode 的 permission 规则数组。
*
* ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西)
*
* 1. **规则是 findLast 胜出**(二进制里
* `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`
* → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。
* 2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree,file)]`
* → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对
* `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。
* 3. **write / edit / patch 共用 `edit` 一个权限名**
* `if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。
* 4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool
* available in this session」部分 deny 则工具保留、越界调用才报错。
* plan 档用前者更好:模型不会浪费轮次去试。
* 5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write
* 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须
* `task deny *`,否则档位形同虚设。
* 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被
* deny 的文件。所以 workspace 档必须同时管 bash只管 edit 没用。
*
* 另注opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档
* **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。
*
* @param {string} mode
* @returns {{permission: string, action: string, pattern: string}[]}
*/
export function opencodePermissions(mode) {
const m = normalizeMode(mode);
if (m === MODE_FULL) {
// 全权:不下发任何规则,用平台自己的默认配置。
// 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。
return [];
}
if (m === MODE_PLAN) {
// 只读。四项都要 deny
// - edit 覆盖 write/edit/patch
// - bash 否则 shell 重定向就能写文件(实测过)
// - task 否则子代理能绕过(实测过)
// - webfetch/websearch 不禁:查资料是 plan 档的本职
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'bash', action: 'deny', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
// workspace目录内可写越界问人。
//
// deny 在前、allow 在后findLast 胜出。pattern `**` 是 worktree
// 相对路径,等价于「这个工作目录内的任何文件」。
//
// bash 一律 ask 而不是 allow命令要碰哪些文件解析不出来
// 这就是「向更严取整」——比声明的严,不比它松。
//
// task 仍然 deny子代理带着自己的权限跑父会话的边界对它无效。
return [
{ permission: 'edit', action: 'deny', pattern: '*' },
{ permission: 'edit', action: 'allow', pattern: '**' },
{ permission: 'bash', action: 'ask', pattern: '*' },
{ permission: 'task', action: 'deny', pattern: '*' },
];
}
/**
* DSH 的沙箱模式。
*
* 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案
* (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES
*
* @param {string} mode
* @returns {'read-only'|'workspace-write'|'danger-full-access'}
*/
export function dshSandboxMode(mode) {
switch (normalizeMode(mode)) {
case MODE_PLAN: return 'read-only';
case MODE_FULL: return 'danger-full-access';
default: return 'workspace-write';
}
}
/**
* DSH 的审批策略。
*
* 关键实测:`danger-full-access` 对应 `approval: "never"`,而
* `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
* **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。
*
* 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`
* 所以整个权限转邮件链路从来没在 dsh 上跑起来过。
*
* @param {string} mode
* @returns {'ask'|'never'}
*/
export function dshApprovalPolicy(mode) {
return modeNeedsHuman(mode) ? 'ask' : 'never';
}
/**
* pi 侧应当守卫的工具名。
*
* pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠
* 「拦哪些工具」表达:
*
* - plan 拦 bash/write/edit读类工具 read/grep/find/ls 不拦)
* - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界,
* bash 无法判断 → 一律问人(向更严取整)
* - full 不拦
*
* @param {string} mode
* @returns {string[]}
*/
export function piGuardedTools(mode) {
return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit'];
}
/**
* pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。
*
* plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」,
* 没什么可问人的,模型该把方案写在回信里。
*
* workspace 档返回 false走问人流程。full 档不会进到这里。
*
* @param {string} mode
* @returns {boolean}
*/
export function piBlocksOutright(mode) {
return normalizeMode(mode) === MODE_PLAN;
}
/**
* 给模型看的档位说明,放进提示词。
*
* 为什么 advisory 时措辞完全不同那种平台homeagent没有任何机制阻止
* 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。
* 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。
*
* @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 dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
if (m === MODE_FULL) {
return '本任务权限档位full全权。工具调用不需要额外授权。';
}
if (m === MODE_PLAN) {
return enforced
? [
'本任务权限档位plan只读。',
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n')
: [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return enforced
? [
`本任务权限档位workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
].join('\n')
: [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}

View File

@ -27,6 +27,8 @@ import {
replyInstruction,
inboundHeadline,
} from '../lib/relay-policy.js';
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
import {
userMessage,
replySubject,
@ -146,34 +148,115 @@ class GatewayClient {
}
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
//
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
// 上限淘汰(兜底)。没有它们,插件跟着 dsh 跑几周之后表里会躺着几十万条再也
// 不会被查到的条目,而 GC 收不掉(还被强引用着)。
const sessionMap = new Map<string, { dshSessionId: string; directory: string }>();
const reverseMap = new Map<string, string>();
const mailDrivenSessions = new Set<string>();
const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS);
const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS);
// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号:
// 前者决定要不要自动转发Agent 间不转,见 lib/relay-policy.js
// 后者决定提示词说「新任务」还是「你上封信的回复到了」。
const mailContexts = new Map<string, {
const mailContexts = new BoundedMap<string, {
replyTo: string; subject: string; mailID: string;
fromHuman: boolean; inReplyTo: string;
}>();
const relayedSummaries = new Map<string, string>();
}>(MAX_TRACKED_SESSIONS);
const relayedSummaries = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels: { provider: string; model: string }[] = [];
const syncedTitles = new Map<string, string>();
const syncedTitles = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
/**
* 会话归档 → 忘掉它的全部映射。
*
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有新邮件
* 投进来,`agent/status` 也不该再把总结转回去(会话已经收不了信)。留着这些条目
* 只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
*
* DSH 侧那个 agent **不 dispose**:人可能还在界面上看它,而且它正在跑的那一轮
* 不该被归档打断。这里只解除邮件绑定。
*/
function forgetSession(mailSessionID: string): void {
if (!mailSessionID) return;
// peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。
const bound = sessionMap.peek(mailSessionID);
mailContexts.delete(mailSessionID);
sessionMap.delete(mailSessionID);
const dshSessionId = bound?.dshSessionId;
if (!dshSessionId) return;
reverseMap.delete(dshSessionId);
mailDrivenSessions.delete(dshSessionId);
relayedSummaries.delete(dshSessionId);
syncedTitles.delete(dshSessionId);
}
// 权限询问DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
// 人类决策通过 SSE 回来后再 resolve 这个 promise让 DSH 自己恢复执行。
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id
// 而同一个 callId 的同一个工具只会问一次。
//
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。静默淘汰
// 一条会让 DSH 侧那个 `await` 永远不返回 —— 那次工具调用直接挂死。它有确定的
// 清理路径(决策到达 / 询问被 abort / 拆插件时 fail closed不需要靠猜。
interface PendingApproval {
resolve: (outcome: string) => void;
sessionId: string;
}
const pendingApprovals = new Map<string, PendingApproval>();
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
//
// 为什么需要这张表DSH 把 `approval/request` 的返回值翻译成模型可见文本时
// 用的是 **dsh-tools 里写死的句子**`node_modules/@deepseek-ai/dsh-tools/lib/index.js`
//
// case "rejected": reason = `the user rejected tool "${exec.name}"`
// case "unavailable": reason = `... no approval channel is available`
//
// 于是插件回 'rejected' 时模型看到的是「the user rejected tool bash」——
// 而实际上**没有任何用户拒绝它**,是「这条链上没有人类可问」或「转发遇到
// 4xx 永久失败」。服务端给的 suggestion换不需要权限的方式 / 在回信里请上游
// 转达)根本没有出口,只进了 journalctl。模型得到的信息既是错的
// 也不含任何可行动的提示 —— 它只会以为人在拒绝它,而不会改道。
//
// pi`{block:true, reason}`)与 opencode`output.reason`)的 reason 直达模型,
// 只有 DSH 把它吞了。出路是 `tools/post-execute`:门禁拒绝的调用**也会**走
// post-execute源码里 `{kind:"post-result"}` → finalizeScheduledExecution
// → postExecute而 `{kind:'block', feedback}` 能换掉模型看到的内容。
interface DeniedReason {
text: string;
at: number;
}
const deniedReasons = new Map<string, DeniedReason>();
/** 10 分钟前的条目不可能还有对应的 post-execute清掉以免无限增长。 */
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
function denialKey(agentId: string, callId: unknown): string {
return `${agentId}:${String(callId ?? 'nocall')}`;
}
function noteDenial(agentId: string, callId: unknown, text: string): void {
const now = Date.now();
for (const [k, v] of deniedReasons) {
if (now - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
}
deniedReasons.set(denialKey(agentId, callId), { text, at: now });
}
function takeDenial(agentId: string, callId: unknown): string | undefined {
const key = denialKey(agentId, callId);
const hit = deniedReasons.get(key);
if (!hit) return undefined;
deniedReasons.delete(key); // 一次性:同一次调用只能被换一次
if (Date.now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
return hit.text;
}
// ─── 运行时导入 DSH 内部函数 ───
let _defineTool: any;
@ -301,7 +384,10 @@ export function apply(ctx: any, config: PluginConfig): void {
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
const deliveredMails = new Set<string>();
//
// 有界:插件跟着 dsh 长期活着,这里会攒下每一封处理过的邮件 id 而永远没有
// 出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
const deliveredMails = new BoundedSet<string>(MAX_TRACKED_MAILS);
let caughtUp = false;
/**
@ -888,7 +974,7 @@ export function apply(ctx: any, config: PluginConfig): void {
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || '',
relay: 'summary',
relay_key: `model-failure:${data.mail_id || sessionId}`,
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionId}`),
});
ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
} catch (e: any) {
@ -981,6 +1067,7 @@ export function apply(ctx: any, config: PluginConfig): void {
cc: args.cc || '', reply_to: args.reply_to || '',
session_alias: args.session_alias || '',
attachment_ids: args.attachment_ids || [],
from_session_id: toolCtx?.sessionID || '',
});
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
const budget = typeof result.budget_remaining === 'number'
@ -1402,7 +1489,7 @@ export function apply(ctx: any, config: PluginConfig): void {
reply_to: mctx.mailID || '',
// relay + relay_key走免配额通道harness 的搬运不该收费)
relay: 'summary',
relay_key: `${agent.id}:${events.length}`,
relay_key: clampRelayKey(`${agent.id}:${events.length}`),
});
relayedSummaries.set(String(agent.id), lastText);
explicitSends.delete(String(agent.id));
@ -1447,7 +1534,9 @@ export function apply(ctx: any, config: PluginConfig): void {
if (!mailSessionID) return next();
// DSH 不给询问发 id用 (会话, 工具, callId) 做幂等键。
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
// clampRelayKey 收尾callId 的长度由上游模型决定pi 侧实测过带思考签名的
// 13601 字节 id超服务端 160 字节列宽直接 400。
const relayKey = clampRelayKey(`${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`);
const mctx = mailContexts.get(mailSessionID);
try {
@ -1483,12 +1572,40 @@ export function apply(ctx: any, config: PluginConfig): void {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}${hint}`);
// 把真正的原因存起来post-execute 会用它换掉 dsh-tools 写死的
// 「the user rejected tool X」—— 没有任何用户拒绝过它。
noteDenial(agentId, req.callId, [
e?.body?.error || `权限询问无法送达:这条任务链上没有人类用户`,
e?.body?.detail || '',
e?.body?.suggestion || '',
].filter(Boolean).join('\n'));
// 用 'rejected' 而不是 'denied'DSH 的 ApprovalOutcome 只认
// allowed-once / rejected / cancelled写错了它不报错而是当成未知值处理。
// allowed-once / rejected / cancelled / unavailable,写错了它不报错
// 而是归一化成 'unavailable'。
return 'rejected';
}
// 其余失败网络抖动、Gateway 重启)是暂时的,交给下一个 answerer本地 UI
console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
// 其余 4xx400 / 401 / 403 / 404 / 422…同样永远不会因重试成功
// 不能 `return next()` —— 下一个 answerer 是本地 UI而邮件驱动的会话
// 没有 UIwaterfall 跑到尾仍旧无人应答。
// pi 侧实测relay_key 过长报 400 被当暂时失败让位,
// 那条 bash 在无人批准的情况下执行了 —— fail closed 才安全。
if (isPermanentFailure(e)) {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(
`[dsh-mail-bridge] 权限询问遇到永久失败HTTP ${e?.status}),当场拒绝 ${relayKey}${hint || e?.message || ''}`,
);
noteDenial(agentId, req.callId, [
`无法把 ${req.toolName} 的授权请求送达给人类HTTP ${e?.status}${e?.body?.error || e?.message || '请求被服务端拒绝'}`,
e?.body?.detail || '',
e?.body?.suggestion || '这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
].filter(Boolean).join('\n'));
return 'rejected';
}
// 暂时失败5xx / 408 / 429 / 网络抖动)交给下一个 answerer本地 UI
console.error(`[dsh-mail-bridge] 权限询问转发暂时失败: ${e?.message || e}`);
return next();
}
@ -1503,6 +1620,40 @@ export function apply(ctx: any, config: PluginConfig): void {
});
});
// 把插件主动拒绝的真正原因递给模型。
//
// DSH 将 approval/request 的 'rejected' 翻译成写死的
// `the user rejected tool "X"`dsh-tools/lib/index.js—— 而当插件因为
// 「没人可问」或「转发遇 4xx」主动拒绝时**没有任何用户拒绝过它**。
// 模型看到一句不存在的拒绝,只会以为人不同意,不会去换一条路;
// 服务端给的 suggestion换不需要权限的方式 / 在回信里请上游转达)则只进了日志。
//
// 门禁拒绝的调用也会进 post-executepre-execute 的 deny 走
// `{kind:"post-result"}` → finalizeScheduledExecution → postExecute
// 而 `{kind:'block', feedback}` 能换掉模型看到的内容 —— 这是 DSH 上
// 唯一能把真实原因送到模型眼前的口子。
//
// pi`{block:true, reason}`)与 opencode`output.reason`)的 reason 直达模型,
// 不需要这道绕行。
ctx.on('tools/post-execute', async (exec: any, result: any, next: () => Promise<any>) => {
const agentId = String(exec?.agent?.id ?? '');
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
// 只管失败的结果:成功的调用不可能是被我们拒绝的那一次。
if (!result?.isError) return next();
const reason = takeDenial(agentId, exec?.callId);
if (!reason) return next();
console.error(`[dsh-mail-bridge] 已把拒绝原因递给模型(${exec?.name}`);
return {
kind: 'block',
feedback: [{
type: 'text',
text: `无法执行 ${exec?.name}${reason}`,
}],
};
});
/** 人类决策回来:先看是不是在等的那条 approval否则当普通通知投给会话。 */
function handlePermissionDecision(data: any): void {
const relayKey = String(data?.relay_key ?? '');
@ -1549,6 +1700,10 @@ export function apply(ctx: any, config: PluginConfig): void {
case 'permission_decision':
handlePermissionDecision(data);
break;
case 'session_archived':
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
forgetSession(String(data?.session_id || ''));
break;
}
});
return () => {

View File

@ -0,0 +1,246 @@
/**
* 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 处理');
});