/** * 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。 * * ## 分工 * * **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'); }