线上事故(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。
277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
/**
|
||
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
|
||
*
|
||
* ## 分工
|
||
*
|
||
* **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;
|
||
}
|
||
|
||
/**
|
||
* **状态写入点**专用的档位解析:拿不到值就返回 `null`(= 不要写),绝不兜默认档。
|
||
*
|
||
* ## 为什么单开一个函数(而不是复用 normalizeMode)
|
||
*
|
||
* `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。
|
||
* 那在**决策**里是对的(有个兜底),在**状态写入**里是错的:
|
||
*
|
||
* 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`,
|
||
* 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write`
|
||
* —— 沙箱窄了、审批从 never 变 ask,之后每个受守卫的工具调用都去问一次,
|
||
* 服务端按真实档位回 409,旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。
|
||
*
|
||
* 一句话规则(pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里**
|
||
* —— 决策有默认值是兜底,状态写入有默认值是**篡改**。
|
||
*
|
||
* 于是分工是:
|
||
* - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**,
|
||
* 保留上一次由服务端给出的权威值;
|
||
* - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档,
|
||
* 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。
|
||
*
|
||
* 跳过写入会不会 fail-open?**实测过方向的**:DSH 的默认沙箱就是
|
||
* `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值),
|
||
* 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽;
|
||
* 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件
|
||
* (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。
|
||
*
|
||
* @param {unknown} mode
|
||
* @returns {string|null} 合法档位;`null` 表示**不要写状态**
|
||
*/
|
||
export function modeForStateWrite(mode) {
|
||
const m = typeof mode === 'string' ? mode.trim() : '';
|
||
if (m === '') return null;
|
||
return normalizeMode(m);
|
||
}
|
||
|
||
/**
|
||
* 取两个档位里更严的那一个。
|
||
*
|
||
* 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。
|
||
* 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');
|
||
}
|