// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。 // // # 这是什么 // // 权限询问默认是**每次都问**:模型每调一次 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> 而不是 Set<`${session}:${tool}`>: * 会话结束时要能一次清掉它的全部授权(`revokeSession`), * 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。 */ export function createGrantStore() { /** @type {Map>} */ 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; }, }; }