feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人
第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。
钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
(MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)
档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。
钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。
fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。
「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。
共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。
验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
mcp: plugin:agentmail:agentmail
我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
最后超时。第一版还把这个超时误报成「拒绝路径通过」。
现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
This commit is contained in:
102
plugins/zcode-mail-bridge/lib/grants-file.mjs
Normal file
102
plugins/zcode-mail-bridge/lib/grants-file.mjs
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 「一直同意」的跨进程持久化。
|
||||
*
|
||||
* # 为什么需要文件
|
||||
*
|
||||
* ZCode 的钩子是**一个事件一个进程** —— 批准完就退出。pi 桥那边的授权集合活在
|
||||
* 常驻 worker 里(靠会话快照跨 worker),而这里没有可依附的常驻内存:
|
||||
* 不落盘的话「一直同意」只在本次调用有效,而下一次调用是个新进程,
|
||||
* 会再问一遍 —— 那个选项就成了骗人的(pi 桥的注释里原话是
|
||||
* 「否则这个选项在骗人」)。
|
||||
*
|
||||
* # 判定语义不在这里
|
||||
*
|
||||
* 「什么是同意」「什么是一直同意」全部来自共用的 `lib/permission-grants.js`
|
||||
* (逐字节同源)。本模块只管把结果存下来,不自己写判定正则 ——
|
||||
* 那正是各平台会悄悄分叉的地方。
|
||||
*
|
||||
* # 并发
|
||||
*
|
||||
* 读-改-写。同一会话的两次授权请求几乎不会同时发生(ZCode 串行执行工具),
|
||||
* 且写入是原子替换(临时文件 + rename),所以最坏情况是「后写覆盖先写」,
|
||||
* 不会读到半截 JSON。真要并发也只会多问一次,不会漏判。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAlwaysDecision } from './permission-grants.js';
|
||||
|
||||
/**
|
||||
* 授权表文件的位置。
|
||||
*
|
||||
* 优先用显式配置,其次插件数据目录(ZCode 会注入 `ZCODE_PLUGIN_DATA`),
|
||||
* 最后退回家目录下的固定名。三者都不存在的情况极罕见,
|
||||
* 但必须有确定答案 —— 退回家目录至少让功能可用。
|
||||
*/
|
||||
export function grantsFilePath(env = process.env) {
|
||||
if (env.AGENTMAIL_ZCODE_GRANTS_FILE) return env.AGENTMAIL_ZCODE_GRANTS_FILE;
|
||||
if (env.AGENTMAIL_CONFIG_DIR) return join(env.AGENTMAIL_CONFIG_DIR, 'permission-grants.json');
|
||||
if (env.ZCODE_PLUGIN_DATA) return join(env.ZCODE_PLUGIN_DATA, 'permission-grants.json');
|
||||
return join(homedir(), '.agentmail-zcode', 'permission-grants.json');
|
||||
}
|
||||
|
||||
/** 读盘。文件不存在或内容坏掉都当空表 —— 授权表读不出来不该让钩子崩。 */
|
||||
export function loadGrants(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const out = new Map();
|
||||
for (const [session, tools] of Object.entries(raw?.sessions ?? {})) {
|
||||
if (Array.isArray(tools)) out.set(session, new Set(tools.filter(t => typeof t === 'string')));
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function saveGrants(filePath, grants) {
|
||||
const sessions = {};
|
||||
for (const [session, tools] of grants) sessions[session] = [...tools];
|
||||
const payload = { version: 1, sessions };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
// 原子替换:直接覆盖写会让并发读者看到半截 JSON(而上面的 load 会把它
|
||||
// 当成空表,于是刚给的授权静默消失)。
|
||||
const tmp = `${filePath}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件的授权表。
|
||||
*
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function createFileGrantStore(filePath) {
|
||||
const grants = loadGrants(filePath);
|
||||
|
||||
return {
|
||||
isGranted(sessionId, toolName) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
return grants.get(sessionId)?.has(toolName) ?? false;
|
||||
},
|
||||
|
||||
/** 只在决策文本确实是「一直同意」时落盘(判定交给共用库)。 */
|
||||
grant(sessionId, toolName, decision) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
if (!isAlwaysDecision(decision)) return false;
|
||||
let set = grants.get(sessionId);
|
||||
if (!set) grants.set(sessionId, (set = new Set()));
|
||||
set.add(toolName);
|
||||
saveGrants(filePath, grants);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** 撤销整条会话的免批(换模型重开会话时用)。 */
|
||||
revokeSession(sessionId) {
|
||||
if (!grants.delete(sessionId)) return false;
|
||||
saveGrants(filePath, grants);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
92
plugins/zcode-mail-bridge/lib/hook-policy.mjs
Normal file
92
plugins/zcode-mail-bridge/lib/hook-policy.mjs
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ZCode 授权钩子的**判定策略**(纯函数,不碰 I/O)。
|
||||
*
|
||||
* 语义与 pi 桥的 `permissionExtension()` 逐条对齐 —— 档位判定是给产品定的,
|
||||
* 不是给平台定的:同一个「plan 档」在 ZCode 上必须是同一个意思,
|
||||
* 否则同一封邮件派到两个 Agent 上会得到两种行为,而人只会以为自己派错了。
|
||||
*
|
||||
* 只把「该做什么」算出来,真正的 I/O(问人、等决定、写 stdout)留在钩子入口,
|
||||
* 于是这里可以被穷举测试。
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeMode,
|
||||
DEFAULT_MODE,
|
||||
MODE_FULL,
|
||||
MODE_PLAN
|
||||
} from './permission-mode.js';
|
||||
|
||||
/**
|
||||
* 被守卫的工具名。
|
||||
*
|
||||
* 对应 pi 桥的 `GUARDED = new Set(['bash', 'write', 'edit'])`。
|
||||
* ZCode 的工具名是首字母大写,且 `Write`/`Edit` 有一个来自 `ApplyPatch` 的别名,
|
||||
* 所以这里做大小写无关匹配并收进 `applypatch`。
|
||||
*/
|
||||
const GUARDED = new Set(['bash', 'write', 'edit', 'applypatch']);
|
||||
|
||||
/** ZCode 的钩子事件名(七个之一)。本模块只关心这一个。 */
|
||||
export const PERMISSION_EVENT = 'PermissionRequest';
|
||||
|
||||
export function isGuardedTool(toolName) {
|
||||
return GUARDED.has(String(toolName ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出这次钩子该采取的动作。
|
||||
*
|
||||
* @param {{event?: string, toolName?: string, mode?: string}} input
|
||||
* @returns {{action: 'none'|'approve'|'block'|'ask', reason?: string}}
|
||||
*
|
||||
* - `none`:不表态。ZCode 会继续它自己的权限流程(该问谁就问谁)——
|
||||
* 这是「不该由我们插手」的唯一正确表达方式;返回 approve 会越权放行,
|
||||
* 返回空字符串 stdout 也一样是「不表态」,但显式写出来更清楚。
|
||||
* - `approve` / `block`:直接给结论。
|
||||
* - `ask`:交给 AgentMail 问人,等决定。
|
||||
*/
|
||||
export function decidePolicy({ event, toolName, mode } = {}) {
|
||||
if (event !== PERMISSION_EVENT) return { action: 'none' };
|
||||
|
||||
// 非守卫工具不表态。钩子的 matcher 已经在 hooks.json 里限定了范围,
|
||||
// 这里再判一次是纵深防御:matcher 被人改宽时不会静默变成「什么都批准」。
|
||||
if (!isGuardedTool(toolName)) return { action: 'none' };
|
||||
|
||||
const m = normalizeMode(mode) || DEFAULT_MODE;
|
||||
|
||||
// full 档:发件人已声明全权,pi 桥在这一档直接不拦截。
|
||||
// ZCode 上「不拦截」的等价物就是批准 —— 钩子一旦触发,ZCode 本会去问人,
|
||||
// 而我们正是要在这一档免掉那个询问。返回 none 会退回询问,语义就反了。
|
||||
if (m === MODE_FULL) return { action: 'approve' };
|
||||
|
||||
// plan 档:该档语义是「只读不动手」,没什么可问人的。
|
||||
// 文案与 pi 桥同源,模型收到的措辞一致。
|
||||
if (m === MODE_PLAN) {
|
||||
return {
|
||||
action: 'block',
|
||||
reason:
|
||||
`plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` +
|
||||
`如需动手请让发件人把档位改成 workspace。`
|
||||
};
|
||||
}
|
||||
|
||||
return { action: 'ask' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次工具调用摘要成人能判断的文本。
|
||||
*
|
||||
* 与 pi 桥的 `describeToolCall` 同源(同样的字段截断长度),
|
||||
* 差别只在 ZCode 的入参字段名(它给的是 `tool_input`)。
|
||||
*/
|
||||
export function describeToolCall(toolName, toolInput) {
|
||||
const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
|
||||
const name = String(toolName ?? '').toLowerCase();
|
||||
if (name === 'bash') {
|
||||
return `命令:\n${String(input.command ?? '').slice(0, 800)}`;
|
||||
}
|
||||
if (name === 'write' || name === 'edit' || name === 'applypatch') {
|
||||
const p = input.file_path ?? input.path ?? input.filePath ?? '(未给出)';
|
||||
return `文件:${p}`;
|
||||
}
|
||||
return JSON.stringify(input).slice(0, 800);
|
||||
}
|
||||
105
plugins/zcode-mail-bridge/lib/permission-grants.js
Normal file
105
plugins/zcode-mail-bridge/lib/permission-grants.js
Normal file
@ -0,0 +1,105 @@
|
||||
// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。
|
||||
//
|
||||
// # 这是什么
|
||||
//
|
||||
// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。
|
||||
// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 ——
|
||||
// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。
|
||||
//
|
||||
// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。
|
||||
//
|
||||
// # 为什么需要一个独立模块
|
||||
//
|
||||
// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权:
|
||||
//
|
||||
// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash,
|
||||
// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。
|
||||
// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。
|
||||
//
|
||||
// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」
|
||||
// 就是在这个粒度上提的问,授权范围不该超出提问范围。
|
||||
//
|
||||
// # 为什么只在内存里
|
||||
//
|
||||
// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings
|
||||
// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程
|
||||
// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。
|
||||
|
||||
/**
|
||||
* 判定一个决策文本是不是「永久同意」。
|
||||
*
|
||||
* **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always,
|
||||
* 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权
|
||||
* 静默升级成永久授权,比不实现这个功能危险得多。
|
||||
*
|
||||
* @param {string} decision 人点的选项原文
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAlwaysDecision(decision) {
|
||||
return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定一个决策文本是不是「同意」(含永久同意)。
|
||||
*
|
||||
* fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者
|
||||
* 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的
|
||||
* 危险操作,比让它失败严重得多。
|
||||
*
|
||||
* @param {string} decision
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isApproval(decision) {
|
||||
return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 免批授权表:`会话 id -> Set<工具名>`。
|
||||
*
|
||||
* 用 Map<string, Set<string>> 而不是 Set<`${session}:${tool}`>:
|
||||
* 会话结束时要能一次清掉它的全部授权(`revokeSession`),
|
||||
* 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。
|
||||
*/
|
||||
export function createGrantStore() {
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const grants = new Map();
|
||||
|
||||
return {
|
||||
/** 这条会话的这个工具是否已获免批。 */
|
||||
isGranted(sessionId, toolName) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
return grants.get(sessionId)?.has(toolName) ?? false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 记下一条免批授权。只在决策文本确实是「一直同意」时才记 ——
|
||||
* 判定交给 isAlwaysDecision,调用方不要自己写正则。
|
||||
* @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志)
|
||||
*/
|
||||
grant(sessionId, toolName, decision) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
if (!isAlwaysDecision(decision)) return false;
|
||||
let set = grants.get(sessionId);
|
||||
if (!set) grants.set(sessionId, (set = new Set()));
|
||||
set.add(toolName);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤销整条会话的免批。
|
||||
*
|
||||
* 换模型重开会话时必须调:授权是人对**那次**上下文的判断,
|
||||
* 新会话重跑一遍提示,不该继承上一条的授权。
|
||||
*/
|
||||
revokeSession(sessionId) {
|
||||
grants.delete(sessionId);
|
||||
},
|
||||
|
||||
/** 仅用于测试与诊断:当前授权总数。 */
|
||||
size() {
|
||||
let n = 0;
|
||||
for (const set of grants.values()) n += set.size;
|
||||
return n;
|
||||
},
|
||||
};
|
||||
}
|
||||
239
plugins/zcode-mail-bridge/lib/permission-mode.js
Normal file
239
plugins/zcode-mail-bridge/lib/permission-mode.js
Normal file
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
|
||||
*
|
||||
* ## 分工
|
||||
*
|
||||
* **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';
|
||||
/**
|
||||
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
|
||||
*
|
||||
* 实测例子:DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
|
||||
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
|
||||
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
|
||||
*/
|
||||
export const ENFORCE_PARTIAL = 'partial';
|
||||
/** 平台没有拦截点,档位只写进提示词。 */
|
||||
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:不能替一个没自报过的平台宣称
|
||||
* 「档位在这里是被强制的」。
|
||||
*
|
||||
* 但**显式自报的值一律原样保留**(含 partial):那是平台自己的事实陈述,
|
||||
* 把它降级到任一极端都是在替它说假话。
|
||||
*
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeEnforcement(e) {
|
||||
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给模型看的档位说明,放进提示词。
|
||||
*
|
||||
* 三种强制力必须说三种话:
|
||||
* - 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 e = normalizeEnforcement(enforcement);
|
||||
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
|
||||
|
||||
if (m === MODE_FULL) {
|
||||
return '本任务权限档位:full(全权)。工具调用不需要额外授权。';
|
||||
}
|
||||
|
||||
if (m === MODE_PLAN) {
|
||||
if (e === ENFORCE_NATIVE) {
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
if (e === ENFORCE_PARTIAL) {
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
|
||||
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
|
||||
'需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (e === ENFORCE_NATIVE) {
|
||||
return [
|
||||
`本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
|
||||
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
|
||||
].join('\n');
|
||||
}
|
||||
if (e === ENFORCE_PARTIAL) {
|
||||
return [
|
||||
`本任务权限档位:workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
|
||||
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`本任务权限档位:workspace。请把改动限制在 ${dir} 内。`,
|
||||
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
|
||||
].join('\n');
|
||||
}
|
||||
127
plugins/zcode-mail-bridge/lib/relay-key.js
Normal file
127
plugins/zcode-mail-bridge/lib/relay-key.js
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* relay_key 长度收敛 —— 四个平台共用。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
*
|
||||
* relay_key 是免配额通道的幂等键,服务端列宽 160 字节(超了返回 400)。
|
||||
* 插件按「会话 id + 某个平台侧调用 id」拼这个键,平常七十来字节,很安全。
|
||||
*
|
||||
* 但生产上踩到一次:pi 会话里 bash 的 relay_key 突然超限,报文
|
||||
* 「relay_key 过长(上限 160 字节)」。查真实会话文件后发现 toolCallId
|
||||
* 有两种形态:
|
||||
*
|
||||
* toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
|
||||
* toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
|
||||
*
|
||||
* 启用 extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId。
|
||||
* 同一条会话里两种形态混着出现,于是同一个 Agent 的权限询问随机成功随机失败。
|
||||
*
|
||||
* 后果不只是「这一次没送达」:那次失败被归入「暂时失败 → 让位给本地决策」,
|
||||
* 而邮件驱动的 worker 没有 TUI,没有人可问 —— 那次 bash 调用**没有任何人
|
||||
* 批准就执行了**。守卫形同虚设。
|
||||
*
|
||||
* ## 为什么用哈希而不是直接截断
|
||||
*
|
||||
* 直接截断会让两次不同的调用撞成同一个键(前缀相同后缀被切掉),
|
||||
* 而这个键的全部意义是幂等:撞键意味着第二次询问被服务端当成重复请求丢掉。
|
||||
* sha256 的碰撞概率可以忽略,且**同样的输入永远得到同样的输出** ——
|
||||
* 这一点是必须的:插件重启后重放同一轮,必须算出同一个键。
|
||||
*
|
||||
* ## 为什么保留可读前缀
|
||||
*
|
||||
* 纯哈希在日志里没法看出是哪条会话。保留前缀让 `grep 会话id` 仍然有用。
|
||||
* 前缀按**字节**截断并回退到字符边界 —— 键里可能有中文(邮件主题派生的键),
|
||||
* 按字符数算会超字节上限,按字节硬切会切出半个字符。
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/** 服务端 relay_key 列宽(字节)。与 gateway 侧 160 保持一致。 */
|
||||
export const RELAY_KEY_MAX_BYTES = 160;
|
||||
|
||||
/** `:sha256:` + 64 位 hex */
|
||||
const HASH_SUFFIX_BYTES = 8 + 64;
|
||||
|
||||
/**
|
||||
* UTF-8 字节数。
|
||||
*
|
||||
* @param {string} s
|
||||
* @returns {number}
|
||||
*/
|
||||
export function byteLength(s) {
|
||||
return Buffer.byteLength(String(s ?? ''), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字节截断,回退到最近的字符边界(不产生半个字符)。
|
||||
*
|
||||
* @param {string} s
|
||||
* @param {number} maxBytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function truncateToBytes(s, maxBytes) {
|
||||
const str = String(s ?? '');
|
||||
if (maxBytes <= 0) return '';
|
||||
const buf = Buffer.from(str, 'utf8');
|
||||
if (buf.length <= maxBytes) return str;
|
||||
|
||||
let end = maxBytes;
|
||||
// UTF-8 续字节是 10xxxxxx。若第一个被丢掉的字节是续字节,
|
||||
// 说明切点落在字符中间 —— 往前退到该字符的首字节之前。
|
||||
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
|
||||
return buf.subarray(0, end).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 relay_key 收敛到服务端能接受的长度。
|
||||
*
|
||||
* 未超限时**原样返回** —— 这一点很重要:绝大多数键本来就合规,
|
||||
* 改写它们会让插件升级前后算出不同的键,等于把已发出的询问变成新询问。
|
||||
*
|
||||
* @param {string} key 原始键
|
||||
* @param {number} [limit] 上限字节数,默认 RELAY_KEY_MAX_BYTES
|
||||
* @returns {string} 长度不超过 limit 的键
|
||||
*/
|
||||
export function clampRelayKey(key, limit = RELAY_KEY_MAX_BYTES) {
|
||||
const raw = String(key ?? '');
|
||||
if (byteLength(raw) <= limit) return raw;
|
||||
|
||||
const hash = createHash('sha256').update(raw, 'utf8').digest('hex');
|
||||
const suffix = `:sha256:${hash}`;
|
||||
// 上限小到装不下哈希时只留哈希(截断哈希仍然确定,只是碰撞面变大;
|
||||
// 这条路径在真实配置下不会走到 —— 160 远大于 72)。
|
||||
if (limit <= HASH_SUFFIX_BYTES) return truncateToBytes(hash, limit);
|
||||
|
||||
const prefix = truncateToBytes(raw, limit - HASH_SUFFIX_BYTES);
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次失败是不是「永远不会成功」。
|
||||
*
|
||||
* ## 为什么必须分类
|
||||
*
|
||||
* 插件在权限询问发送失败时有两条路:让位给平台本地决策,或当场 block。
|
||||
* 原来除 409 之外一律当「暂时失败」让位 —— 而 400(请求本身不合法)
|
||||
* 重试一万次也是 400。邮件驱动的会话**没有本地 UI**,让位等于让守卫消失:
|
||||
* 生产实测一次 bash 就这样在无人批准的情况下执行了。
|
||||
*
|
||||
* ## 判据
|
||||
*
|
||||
* - 4xx(除 408 / 429)= 永久:请求本身有问题,重试不会变好
|
||||
* - 408 / 429 = 暂时:超时与限流,等一会儿真的可能成功
|
||||
* - 5xx = 暂时:服务端的问题
|
||||
* - 无 status(网络层错误、DNS、连接被拒)= 暂时
|
||||
*
|
||||
* 401 归到永久:密钥无效要人去后台重新登记,不是等一等就好的事
|
||||
* (本会话实测过一次 —— opencode 拿着已撤销的密钥重试了 18 小时)。
|
||||
*
|
||||
* @param {{status?: number}} err
|
||||
* @returns {boolean} true = 永久失败,插件必须当场表态
|
||||
*/
|
||||
export function isPermanentFailure(err) {
|
||||
const status = Number(err?.status);
|
||||
if (!Number.isFinite(status) || status <= 0) return false; // 网络层错误 → 暂时
|
||||
if (status === 408 || status === 429) return false; // 超时 / 限流 → 暂时
|
||||
return status >= 400 && status < 500;
|
||||
}
|
||||
179
plugins/zcode-mail-bridge/lib/sse-client.js
Normal file
179
plugins/zcode-mail-bridge/lib/sse-client.js
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 共用 SSE 客户端:跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
|
||||
*
|
||||
* 三平台桥原本各自手写 SSE 解析,且都有同一个 bug:
|
||||
* - `evt` / `data` 是每次 `read()` 的局部变量,TCP 把一帧
|
||||
* `event: xxx\ndata: {...}\n\n` 切在换行处时,第一段只剩
|
||||
* `event:` 而第二段只有 `data:` —— 整帧被静默丢弃。
|
||||
* - 重连不带 `Last-Event-ID`,断线期间的事件只在服务端环形
|
||||
* 缓冲里等着,永远回放不出来(Gateway 有 per-agent ring buffer,
|
||||
* pi 与 homeagent 已正确利用,DSH/opencode 没有)。
|
||||
*
|
||||
* 这个模块把 pi 桥 `src/gateway.mjs` 里那份验证过的实现抽成共用件,
|
||||
* 三桥逐字节同源(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 增量 SSE 帧解析器。
|
||||
*
|
||||
* `push(chunk)` 可以喂任意切分的文本片段,返回本次完整解析出的事件数组。
|
||||
* 所有跨帧状态(缓冲、当前 event/data/id)都保存在闭包里,**不随 chunk 重置** ——
|
||||
* 这正是原实现丢帧的根因。
|
||||
*
|
||||
* 协议细节:
|
||||
* - `:` 开头 = 注释/心跳,忽略
|
||||
* - `id:` / `event:` / `data:` 各取字段;`data:` 后的单个空格是分隔符
|
||||
* - 多行 data 用 `\n` 拼接
|
||||
* - 空行 = 帧结束;只有 event 与 data 都非空才派发(与旧行为一致)
|
||||
* - 兼容 CRLF
|
||||
* - `lastEventId` 在**派发之前**记下:回调抛异常也不该让断点回退。
|
||||
*
|
||||
* @returns {{push: (chunk: string) => Array<{event: string, data: string, id: string}>,
|
||||
* reset: () => void,
|
||||
* lastEventId: () => string,
|
||||
* setLastEventId: (id: string) => void}}
|
||||
*/
|
||||
export function createFrameParser() {
|
||||
let buffer = '';
|
||||
let lastEventId = '';
|
||||
let curEvent = '';
|
||||
let curData = '';
|
||||
let curId = '';
|
||||
|
||||
function push(chunk) {
|
||||
buffer += chunk;
|
||||
const events = [];
|
||||
const lines = buffer.split('\n');
|
||||
// 最后一段可能是被切断的半行,留到下一个 chunk
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith('id:')) {
|
||||
curId = line.slice(3).trim();
|
||||
} else if (line.startsWith('event:')) {
|
||||
curEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
let value = line.slice(5);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
||||
} else if (line === '') {
|
||||
if (curEvent.length > 0 && curData.length > 0) {
|
||||
if (curId.length > 0) lastEventId = curId;
|
||||
events.push({ event: curEvent, data: curData, id: curId });
|
||||
}
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
buffer = '';
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
reset,
|
||||
lastEventId: () => lastEventId,
|
||||
setLastEventId: (id) => { lastEventId = id || ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {() => Record<string,string>} deps.authHeaders 认证头(每次重连重取,密钥可能已换)
|
||||
* @param {string} deps.baseURL Gateway 基地址(不带末尾 /)
|
||||
* @param {string} deps.path SSE 路径(如 /api/v1/events/stream)
|
||||
* @param {(evt: string, data: any) => void} deps.onEvent 事件分发回调
|
||||
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error)
|
||||
* @returns {{stop: () => void}} stop() 终止重连与在途请求
|
||||
*/
|
||||
export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) {
|
||||
const controller = new AbortController();
|
||||
const parser = createFrameParser();
|
||||
|
||||
function stop() {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
function reconnect(delay) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeout(() => connect(), delay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const headers = { ...authHeaders(), Accept: 'text/event-stream' };
|
||||
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
||||
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
const lastEventID = parser.lastEventId();
|
||||
if (lastEventID) {
|
||||
headers['Last-Event-ID'] = lastEventID;
|
||||
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function read() {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
parser.reset();
|
||||
return reconnect(3000);
|
||||
}
|
||||
for (const ev of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
try {
|
||||
onEvent(ev.event, JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
stop,
|
||||
/**
|
||||
* 清掉断点(不终止连接)。
|
||||
*
|
||||
* 换 Gateway 地址时必须调:lastEventID 是**旧** Gateway 环形缓冲里的序号,
|
||||
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
||||
* 得到的事件属于别人的会话。
|
||||
*/
|
||||
reset: () => parser.setLastEventId(''),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user