UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

355 lines (354 loc) 21 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.writeDeletionBlockNotice = exports.formatDeletionLines = exports.deletionBanner = exports.countDeletions = exports.tryPassDeletionGate = exports.removePendingDeletion = exports.readPendingDeletion = exports.writePendingDeletion = exports.deletionGrantTokenPasses = exports.decodeDeletionToken = exports.encodeDeletionToken = exports.hasDeleteGrant = exports.consumeDeleteGrant = exports.writeDeleteGrant = exports.setStandingAllow = exports.getStandingAllowStatus = exports.hasStandingAllow = exports.computeNonce = exports.deletionTokens = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const crypto_1 = __importDefault(require("crypto")); const auth_config_1 = require("../../utils/auth-config"); const workspace_1 = require("../../utils/workspace"); /** * 删除(点位 / 属性)感知闸口 + 三档授权模型。 * * 定位(务必如实):这套**最大化"让用户感知删除 + 逼守约定的 AI 喊人",不是技术强制**。 * 唯一硬保证:删除不会在第一次调用就发生(exit 2,fail-closed);CLI 在 AI 下游,保证不了 * 那第二步授权是「人」做的。真正"挡任何 AI"只能放 harness(之上)或后端(之外),不在此。 * 详见 docs/superpowers/specs/2026-06-17-ai-property-deletion-gate-design.md。 */ // ───────────────────────── tokens & nonce ───────────────────────── /** 汇总一份 diff 里所有「会删除的东西」为 canonical token:点位 `<type>[<key>]`、属性 `<type>[<key>].<array>.<id>`。 */ function deletionTokens(diffs) { var _a; const out = []; for (const d of diffs) { if (d.status === 'deleted') out.push(`${d.type}[${d.key}]`); for (const rp of (_a = d.removedProperties) !== null && _a !== void 0 ? _a : []) out.push(`${d.type}[${d.key}].${rp}`); } return out; } exports.deletionTokens = deletionTokens; /** * nonce 绑死「本次删除集 + 所属插件」:tokens 或 pluginId 任一变 → nonce 变 → 旧 grant 失效。 * pluginId 进 hash 输入(而非可见 token)→ 授权按工程隔离:A、B 两个工程即便删除集逐字节相同, * nonce 也不同,grant 不会跨工程串用;allow-delete 仍只按完整 nonce 匹配、无需知道 pluginId。 * 稳定排序后取 sha256 前 12 位。 */ function computeNonce(tokens, pluginId) { const canon = `${pluginId}\n${[...tokens].sort().join('\n')}`; return crypto_1.default.createHash('sha256').update(canon).digest('hex').slice(0, 12); } exports.computeNonce = computeNonce; // 真实 nonce 恒为 sha256 前 12 位小写 hex。grant / pending 文件名直接由 nonce 拼成,所以构造路径前 // 必须卡住格式——拒绝含 `..` / 路径分隔符 / 绝对路径前缀的输入(如 allow-delete 收到的未净化位置参数), // 防其拼进 path.join 后读穿越(`../../../tmp/evil` → /tmp/evil.json)或经植入 pending 写穿越。 const NONCE_RE = /^[0-9a-f]{12}$/; function assertValidNonce(nonce) { if (typeof nonce !== 'string' || !NONCE_RE.test(nonce)) { throw new Error(`invalid deletion nonce: ${JSON.stringify(nonce)}`); } } // ───────────────────────── standing allow(三档:项目 / 全局) ───────────────────────── const STANDING_FILE = 'config.json'; function readAllowDeleteFlag(file) { var _a; try { return ((_a = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'))) === null || _a === void 0 ? void 0 : _a.allowDelete) === true; } catch (_b) { return false; } } /** 项目 `<cwd>/.lpm/config.json` 或全局 `~/.lpm/config.json` 任一 `allowDelete:true` → standing allow。 */ function hasStandingAllow(cwd = process.cwd()) { return (readAllowDeleteFlag(path_1.default.join(cwd, '.lpm', STANDING_FILE)) || readAllowDeleteFlag(path_1.default.join((0, auth_config_1.resolveConfigDir)(), STANDING_FILE))); } exports.hasStandingAllow = hasStandingAllow; /** 分别返回项目 / 全局 standing allow 的开启状态(供 `lpm allow-delete --status`)。 */ function getStandingAllowStatus(cwd = process.cwd()) { return { project: readAllowDeleteFlag(path_1.default.join(cwd, '.lpm', STANDING_FILE)), global: readAllowDeleteFlag(path_1.default.join((0, auth_config_1.resolveConfigDir)(), STANDING_FILE)), }; } exports.getStandingAllowStatus = getStandingAllowStatus; /** 设/清 standing allow(高风险弃权——调用方负责在设置当下打风险警示 + 取用户确认)。返回写入的文件路径。 */ function setStandingAllow(scope, allow, cwd = process.cwd()) { // 项目级 standing allow 落 <cwd>/.lpm/——cwd 必须是插件工程根,否则拒绝(防在错误目录 litter .lpm/)。 // 全局档落 ~/.lpm(resolveConfigDir),与工程根无关,不走此守卫。 if (scope === 'project') (0, workspace_1.assertPluginRoot)(cwd); const dir = scope === 'project' ? path_1.default.join(cwd, '.lpm') : (0, auth_config_1.resolveConfigDir)(); const file = path_1.default.join(dir, STANDING_FILE); let cfg = {}; try { cfg = JSON.parse(fs_1.default.readFileSync(file, 'utf-8')) || {}; } catch (_a) { cfg = {}; } cfg.allowDelete = allow; fs_1.default.mkdirSync(dir, { recursive: true }); fs_1.default.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o644 }); return file; } exports.setStandingAllow = setStandingAllow; // ───────────────────────── 一次性 grant(用完即焚) ───────────────────────── // 交接文件(pending / grant)落「项目目录」<cwd>/.lpm/ 而非全局 ~/.lpm:项目目录是 AI-IDE 沙箱 // bind-mount 进来的共享 workdir(沙箱内外同一绝对路径可见),全局 home 在沙箱里是私有的、跨不过去。 // 所以授权交接走项目目录,agent(沙箱)写、用户(host)在同一项目目录里读得到,反之亦然——无需 token 往返。 // 与项目级 standing allow 同栖 <cwd>/.lpm/(见 hasStandingAllow),且 .lpm 已 gitignore。 function grantsDir(cwd) { return path_1.default.join(cwd, '.lpm', 'delete-grants'); } function grantPath(nonce, cwd) { assertValidNonce(nonce); return path_1.default.join(grantsDir(cwd), `${nonce}.json`); } /** * 写一次性删除授权(由 `lpm allow-delete` 在用户确认后调用)。返回写入路径。 * cwd 必须是插件工程根,否则 throw——与 writePendingDeletion / setStandingAllow 一致,绝不在错误目录 litter .lpm/。 * 调用方(allow-delete)负责在 token 兜底流里 catch:那条流靠回传 grant token 放行,本地 grant 文件对它无用。 */ function writeDeleteGrant(nonce, tokens, cwd = process.cwd()) { (0, workspace_1.assertPluginRoot)(cwd); const dir = grantsDir(cwd); fs_1.default.mkdirSync(dir, { recursive: true }); const p = grantPath(nonce, cwd); fs_1.default.writeFileSync(p, JSON.stringify({ nonce, tokens }, null, 2), { mode: 0o600 }); return p; } exports.writeDeleteGrant = writeDeleteGrant; /** 命中则消费(删除授权文件)并返回 true;一次性、用完即焚。 */ function consumeDeleteGrant(nonce, cwd = process.cwd()) { try { if (!fs_1.default.existsSync(grantPath(nonce, cwd))) return false; fs_1.default.unlinkSync(grantPath(nonce, cwd)); return true; } catch (_a) { return false; } } exports.consumeDeleteGrant = consumeDeleteGrant; /** 授权是否存在(不消费)——`set` 用它「放行但不消费」,把一次性消费留给 `update` 的真推送。 */ function hasDeleteGrant(nonce, cwd = process.cwd()) { try { return fs_1.default.existsSync(grantPath(nonce, cwd)); } catch (_a) { return false; } } exports.hasDeleteGrant = hasDeleteGrant; // ───────────────────────── stateless grant token(沙箱场景:跨文件系统靠 copy-paste 的 token,不落本地文件) ───────────────────────── // // 为什么需要它:闸口的文件交接(pending / grant)默认「拦截」和「授权」在同一台机器、同一个 ~/.lpm。 // 但 AI-IDE 常把 agent 放在隔离沙箱里跑命令——agent 的 `update` 把 pending 写进沙箱 FS,用户在主机终端 // 跑 `allow-delete` 读不到,反之 grant 也跨不回去。token 化把这两次交接改成「人来回 copy-paste」: // ① 拦截时 `update`/`set` 除了写 pending,再打印一段自包含 token T(编码 pluginId + 删除集); // ② 用户在自己终端 `allow-delete <T>` 解码出权威清单、确认后打印 grant token G; // ③ 用户把 G 回贴给 agent,`update --delete-grant <G>` 携带它回去放行。 // 可信根不是密码学(本地无密钥、沙箱内 agent 理论上能自造 token,与「能自己写 grant 文件」同档), // 而是放行那步 deletionGrantTokenPasses 会用「当前真实删除集」重算 nonce、必须匹配——保证只能删 token // 授权的那一批,删多/删错/张冠李戴别的工程都拦。要「真挡住 agent」只能放后端验签(沙箱外),见 spec。 const TOKEN_PREFIX = 'LPMDEL1.'; /** 精简 diff:只留展示删除清单所需字段,剔掉 changes 里大段 local/remote 转储,token 才短到能 copy-paste。 */ function slimDiffsForToken(diffs) { return diffs .filter(d => { var _a, _b; return d.status === 'deleted' || ((_b = (_a = d.removedProperties) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0; }) .map(d => { var _a; const slim = { type: d.type, key: d.key, status: d.status }; if (d.name) slim.name = d.name; if ((_a = d.removedProperties) === null || _a === void 0 ? void 0 : _a.length) slim.removedProperties = [...d.removedProperties]; return slim; }); } /** 把「本次删除集 + 所属插件」编码成自包含、可 copy-paste 的 token(`LPMDEL1.<base64url(payload)>`)。 */ function encodeDeletionToken(pluginId, diffs) { const tokens = deletionTokens(diffs); const payload = { v: 1, pluginId, nonce: computeNonce(tokens, pluginId), tokens, diffs: slimDiffsForToken(diffs), }; return TOKEN_PREFIX + Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); } exports.encodeDeletionToken = encodeDeletionToken; /** 解码 + 自校验(nonce 必须 = computeNonce(tokens, pluginId))。破损 / 篡改 / 非 token / 空 → undefined。 */ function decodeDeletionToken(token) { if (!token) return undefined; const trimmed = token.trim(); if (!trimmed.startsWith(TOKEN_PREFIX)) return undefined; try { const b64 = trimmed.slice(TOKEN_PREFIX.length).replace(/\s+/g, ''); const p = JSON.parse(Buffer.from(b64, 'base64url').toString('utf-8')); if ((p === null || p === void 0 ? void 0 : p.v) !== 1 || typeof p.pluginId !== 'string' || typeof p.nonce !== 'string' || !Array.isArray(p.tokens) || !Array.isArray(p.diffs)) { return undefined; } // 自校验:pluginId / tokens 被改过 → nonce 对不上 → 拒绝(挡 copy 损坏 + 手改核心字段)。 if (computeNonce(p.tokens, p.pluginId) !== p.nonce) return undefined; return p; } catch (_a) { return undefined; } } exports.decodeDeletionToken = decodeDeletionToken; /** * grant token 是否授权「此刻这批删除」:解码 + 校验 token 的 nonce 等于用「当前真实删除集 + pluginId」 * 重算的 nonce。token 多删 / 少删 / 属于别的工程 → nonce 不等 → 不放行。 */ function deletionGrantTokenPasses(token, pluginId, diffs) { const decoded = decodeDeletionToken(token); if (!decoded) return false; return decoded.pluginId === pluginId && decoded.nonce === computeNonce(deletionTokens(diffs), pluginId); } exports.deletionGrantTokenPasses = deletionGrantTokenPasses; // ───────────────────────── pending(被拦那一刻的权威清单,供 allow-delete 读) ───────────────────────── // pending 同 grant,落项目目录 <cwd>/.lpm/(共享 workdir,沙箱内外同路径可见)。见上方 grantsDir 注释。 function pendingDir(cwd) { return path_1.default.join(cwd, '.lpm', 'delete-pending'); } function pendingPath(nonce, cwd) { assertValidNonce(nonce); return path_1.default.join(pendingDir(cwd), `${nonce}.json`); } /** 闸口拦下时持久化本次删除清单(`set` 在写本地前就 exit 2,draft 未落盘,故 allow-delete 不能靠重算)。 */ function writePendingDeletion(nonce, diffs, pluginId, cwd = process.cwd()) { try { // cwd 必须是插件工程根——非插件目录直接拒绝落盘(best-effort 吞掉),防在错误目录 litter .lpm/。 // 正常流程里 set/update 跑在工程根,守卫恒过;只在被误调时挡住污染(token 兜底仍可授权)。 (0, workspace_1.assertPluginRoot)(cwd); const dir = pendingDir(cwd); fs_1.default.mkdirSync(dir, { recursive: true }); const record = { nonce, tokens: deletionTokens(diffs), diffs, pluginId }; fs_1.default.writeFileSync(pendingPath(nonce, cwd), JSON.stringify(record, null, 2), { mode: 0o600 }); } catch (_a) { // best-effort:pending 落盘失败(或 cwd 非工程根被守卫拒)不影响"已经 exit 2 拦住"这件事 } } exports.writePendingDeletion = writePendingDeletion; function readPendingDeletion(nonce, cwd = process.cwd()) { try { return JSON.parse(fs_1.default.readFileSync(pendingPath(nonce, cwd), 'utf-8')); } catch (_a) { return undefined; } } exports.readPendingDeletion = readPendingDeletion; function removePendingDeletion(nonce, cwd = process.cwd()) { try { fs_1.default.unlinkSync(pendingPath(nonce, cwd)); } catch (_a) { /* ignore */ } } exports.removePendingDeletion = removePendingDeletion; /** * 评估删除闸口。无删除 → pass=true / hasDeletion=false。调用方:`!pass && hasDeletion` → 打 notice + exit 2。 * 放行优先级:standing allow → grant token(无状态、跨沙箱)→ 本地一次性 grant 文件。 * `opts.consume`(默认 true):本地 grant **文件**命中时是否消费(grant token 是无状态的,本就不消费)。 * `update`(不可逆推送)消费;`set`(可逆写本地)只「探测不消费」,把一次性消费留给 update——授权一次两步都过。 * `opts.grantToken`:用户 `allow-delete` 打印、经命令行 `--delete-grant` 回传的 grant token(沙箱场景)。 */ function tryPassDeletionGate(diffs, pluginId, cwd = process.cwd(), opts = {}) { var _a; const consume = (_a = opts.consume) !== null && _a !== void 0 ? _a : true; const tokens = deletionTokens(diffs); const nonce = computeNonce(tokens, pluginId); if (tokens.length === 0) return { tokens, nonce, hasDeletion: false, pass: true, via: 'none' }; if (hasStandingAllow(cwd)) return { tokens, nonce, hasDeletion: true, pass: true, via: 'standing' }; if (deletionGrantTokenPasses(opts.grantToken, pluginId, diffs)) return { tokens, nonce, hasDeletion: true, pass: true, via: 'grant-token' }; const grantHit = consume ? consumeDeleteGrant(nonce, cwd) : hasDeleteGrant(nonce, cwd); if (grantHit) return { tokens, nonce, hasDeletion: true, pass: true, via: 'grant' }; return { tokens, nonce, hasDeletion: true, pass: false, via: 'none' }; } exports.tryPassDeletionGate = tryPassDeletionGate; // ───────────────────────── 展示(感知面) ───────────────────────── function countDeletions(diffs) { return { points: diffs.filter(d => d.status === 'deleted').length, properties: diffs.reduce((a, d) => { var _a, _b; return a + ((_b = (_a = d.removedProperties) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0); }, 0), }; } exports.countDeletions = countDeletions; /** * 顶部汇总横幅——「扫一眼也漏不掉」。无删除返回 ''。 * 先打**总数**再分维度,避免「0 个点位、…」开头那个 0 抢视觉、让人误读成"没删东西" * (只删属性、点位本身没删时尤其会被埋)。 */ function deletionBanner(diffs) { const { points, properties } = countDeletions(diffs); const total = points + properties; if (total === 0) return ''; return `⚠️ 本次包含删除:共 ${total} 项(点位 ${points} / 属性 ${properties})`; } exports.deletionBanner = deletionBanner; /** 删除清单两段:整点位 / 属性。每段为空则不出现。 */ function formatDeletionLines(diffs) { var _a; const lines = []; const deletedPoints = diffs.filter(d => d.status === 'deleted'); if (deletedPoints.length > 0) { lines.push(' 整点位删除(remote 有 / local 无):'); for (const d of deletedPoints) lines.push(` - ${d.type}[${d.key}]${d.name ? ` "${d.name}"` : ''}`); } const propDeletions = diffs.filter(d => { var _a, _b; return ((_b = (_a = d.removedProperties) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0; }); if (propDeletions.length > 0) { lines.push(' 属性删除(点位保留、仅删属性):'); for (const d of propDeletions) { for (const rp of (_a = d.removedProperties) !== null && _a !== void 0 ? _a : []) lines.push(` - ${d.type}[${d.key}].${rp}`); } } return lines; } exports.formatDeletionLines = formatDeletionLines; /** * 删除被拦时写 stderr。文案承重:**把授权指派给「用户」、不给 AI 递自助解锁配方**。 * `lpm allow-delete` 由用户在自己终端跑、打权威清单 + 强制确认。 * * 主路径(同一项目目录):pending / grant 落项目 <cwd>/.lpm/(沙箱 bind-mount 的共享 workdir, * 沙箱内外同绝对路径可见)。打印 `lpm --cwd <abs> allow-delete <nonce>`——带 `--cwd` 让用户在任意 * 目录都能跑、且必定落到同一个项目 .lpm/(防止在错的目录跑找不到 pending)。 * 兜底(极少:项目目录都不共享):自包含 token——用户 `allow-delete <token>` → grant token G → * agent `update --delete-grant <G>`,全程不依赖任何共享目录。`pluginId` 进 pending + token。 */ function writeDeletionBlockNotice(diffs, nonce, pluginId, cwd = process.cwd()) { writePendingDeletion(nonce, diffs, pluginId, cwd); // 持久化权威清单到项目 .lpm/,供用户随后跑 allow-delete 读取 const token = encodeDeletionToken(pluginId, diffs); // 自包含 token,兜底(项目目录都不共享时) const out = []; out.push('', deletionBanner(diffs), '⚠️ DELETION_REQUIRES_CONFIRMATION'); out.push(...formatDeletionLines(diffs)); out.push('', '→ 这一步需要【用户】:把以上删除清单转呈用户,请其在【自己的终端】执行:', ` lpm --cwd ${cwd} allow-delete ${nonce}`, ' 该命令打印权威删除清单 + 强制确认,授权后重跑即放行。授权记录落在项目目录 .lpm/ 下', ' (沙箱与你的终端共享同一项目目录,所以在同一个项目里授权即生效,无需复制 token)。', ' 你(AI)不要自己运行 lpm allow-delete,也不要设置 standing allow。删除点位 / 属性不可逆。', '', '→ 兜底(仅当项目目录在用户终端与本命令之间都不共享时):改用自包含 token——', ' 用户跑 `lpm allow-delete <token>` 确认后,把回传的 grant token 用', ' `lpm update --source-type=local --delete-grant <grant token>` 放行(不依赖任何共享目录)。', ` token: ${token}`, ''); process.stderr.write(out.join('\n') + '\n'); } exports.writeDeletionBlockNotice = writeDeletionBlockNotice;