@lark-project/cli
Version:
飞书项目插件开发工具
355 lines (354 loc) • 21 kB
JavaScript
;
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;