/** * VerifyPlan — 结构化验证计划。 * * 所有 verify command 先变成 VerifyPlan,再由 GateRunner 判定是否可执行。 * 禁止任意 shell 命令,只允许 npm run {script}。 * * @see docs/AGENT_GATE_LOOP_REFACTOR_PLAN.md — "Phase 3: 验证命令" */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { GateCheckpoint, GateSeverity } from "./action-plan.ts"; import type { GateDecision } from "../gates/findings.ts"; import type { GateRunner } from "../gates/gate-runner.ts"; import type { ActiveRun } from "../types.ts"; // --------------------------------------------------------------------------- // VerifyPlan // --------------------------------------------------------------------------- /** * VerifyPlan — 通过门禁的结构化验证计划。 * * 每个 VerifyPlan 必须: * - 绑定一个 CompletionPromise(completionPromiseId)。 * - 携带门禁追踪 ID(gateTraceId)。 * - 命令只能是 `npm run {scriptName}` 形式。 * - scriptName 必须存在于 package.json 的 scripts 中。 */ export interface VerifyPlan { /** 唯一标识,格式:plan-{timestamp}-{random_hex_4}。 */ planId: string; /** 验证命令,必须为 "npm run {scriptName}" 形式。 */ command: string; /** package.json 中的脚本名称。 */ scriptName: string; /** 最大重试次数。 */ maxAttempts: number; /** 关联的 Completion Promise ID。 */ completionPromiseId: string; /** 关联的门禁决策追踪 ID。 */ gateTraceId: string; /** 验证证据输出路径。 */ evidencePath?: string; } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- /** VerifyPlan 验证错误。 */ export interface VerifyPlanValidationError { severity: GateSeverity; checkpoint: GateCheckpoint; message: string; } /** * VerifyPlan 完整校验结果,包含验证错误和门禁决策。 * * 当 gateRunner 可用时,会通过 `pre-verify-command` 检查点运行门禁策略, * 将门禁决策附加到结果中。 */ export interface VerifyPlanValidationResult { /** 所有验证错误;空数组表示校验通过。 */ errors: VerifyPlanValidationError[]; /** 门禁决策(仅当 gateRunner 可用时存在)。 */ gateDecision?: GateDecision; /** 最终是否允许执行:errors 为空 且 gateDecision.allowed === true。 */ allowed: boolean; } /** 被禁止的 shell 命令模式。 */ const FORBIDDEN_COMMAND_PATTERNS: RegExp[] = [ /\bnode\s+-e\b/, /\bpython\s+-c\b/, /\bnpx\s+\S+/, /\bbash\s+-c\b/, /\bpwsh\s+-c\b/, /\bpowershell\s+-c\b/, /\bcmd\s+\/c\b/, /;\s*\S/, /\|\s*\S/, /&&\s*\S/, /\$\(/, /`[^`]+`/, ]; /** * 从 package.json 读取 scripts 名称列表。 * * @param cwd - 工作区根目录。 * @returns scripts 名称集合。 */ function readPackageScripts(cwd: string): Set { try { const raw = readFileSync(join(cwd, "package.json"), "utf-8"); const pkg = JSON.parse(raw) as { scripts?: Record }; return new Set(Object.keys(pkg.scripts ?? {})); } catch { return new Set(); } } /** * 校验 scriptName 是否存在于 package.json scripts 中。 * * @param cwd - 工作区根目录。 * @param scriptName - 待校验的脚本名。 * @returns 若脚本不存在返回错误信息,否则 undefined。 */ export function validateScriptName(cwd: string, scriptName: string): string | undefined { const scripts = readPackageScripts(cwd); if (!scripts.has(scriptName)) { return `script "${scriptName}" 不存在于 package.json scripts 中。可用脚本:${Array.from(scripts).join(", ")}`; } return undefined; } /** * 校验 command 是否为合法的 "npm run {script}" 形式且不包含危险 shell 模式。 * * @param command - 待校验的命令。 * @returns 若命令不合法返回错误信息,否则 undefined。 */ export function validateVerifyCommand(command: string): string | undefined { const trimmed = command.trim(); // 必须以 npm run 开头 const npmRunMatch = trimmed.match(/^npm\s+run\s+(\S+)$/); if (!npmRunMatch) { return `验证命令必须为 "npm run {script}" 形式,收到:"${trimmed}"`; } // 检查禁止的 shell 模式 for (const pattern of FORBIDDEN_COMMAND_PATTERNS) { if (pattern.test(trimmed)) { return `验证命令包含被禁止的 shell 模式(${pattern.source}):${trimmed}`; } } return undefined; } /** * 完整校验 VerifyPlan。 * * 校验流程: * 1. 验证 command 格式(必须为 npm run {script})。 * 2. 验证禁止的 shell 模式。 * 3. 验证 scriptName 存在于 package.json。 * 4. 验证 command 中的脚本名与 scriptName 一致。 * 5. 若提供 gateRunner,运行 pre-verify-command 门禁策略。 * * @param plan - 待校验的 VerifyPlan。 * @param cwd - 工作区根目录。 * @param options - 可选的门禁集成参数。 * @param options.gateRunner - GateRunner 实例,用于运行 pre-verify-command 门禁。 * @param options.run - 当前 ActiveRun,传递给门禁上下文。 * @returns 完整校验结果,包含错误列表、门禁决策和最终是否允许执行。 */ export async function validateVerifyPlan( plan: VerifyPlan, cwd: string, options?: { gateRunner?: GateRunner; run?: ActiveRun }, ): Promise { const errors: VerifyPlanValidationError[] = []; const commandError = validateVerifyCommand(plan.command); if (commandError) { errors.push({ severity: "hard-deny", checkpoint: "pre-verify-command", message: commandError, }); } const scriptError = validateScriptName(cwd, plan.scriptName); if (scriptError) { errors.push({ severity: "hard-deny", checkpoint: "pre-verify-command", message: scriptError, }); } // command 中的脚本名和 scriptName 必须一致 const npmRunMatch = plan.command.trim().match(/^npm\s+run\s+(\S+)$/); if (npmRunMatch && npmRunMatch[1] !== plan.scriptName) { errors.push({ severity: "hard-deny", checkpoint: "pre-verify-command", message: `命令中的脚本名 "${npmRunMatch[1]}" 与 scriptName "${plan.scriptName}" 不一致`, }); } // ── GateRunner integration ────────────────────────────────────────────── let gateDecision: GateDecision | undefined; if (options?.gateRunner) { gateDecision = await options.gateRunner.runGate({ cwd, checkpoint: "pre-verify-command", run: options.run, command: plan.command, }); if (!gateDecision.allowed) { const gateMessages = gateDecision.findings .filter((f) => f.severity === "hard-deny" || f.severity === "soft-deny") .map((f) => `[${f.policy}] ${f.message}`) .join("; "); errors.push({ severity: "hard-deny", checkpoint: "pre-verify-command", message: `门禁拒绝执行验证命令:${gateMessages || "pre-verify-command gate blocked"}`, }); } } const allowed = errors.length === 0; return { errors, gateDecision, allowed }; } // --------------------------------------------------------------------------- // Plan ID generation (shared format) // --------------------------------------------------------------------------- function generatePlanId(): string { const timestamp = Date.now(); const randomHex = Math.floor(Math.random() * 0x10000) .toString(16) .padStart(4, "0"); return `plan-${timestamp}-${randomHex}`; } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * 创建 VerifyPlan 实例。 * * 创建时执行基础校验(command 格式、禁止模式),但不校验 package.json。 * 运行时校验(含 scriptName 存在性)由 {@link validateVerifyPlan} 执行。 * * @param scriptName - package.json 中的脚本名称。 * @param completionPromiseId - 关联的 Completion Promise ID。 * @param gateTraceId - 关联的门禁追踪 ID。 * @param options - 可选项。 * @param options.maxAttempts - 最大重试次数,默认 1。 * @param options.evidencePath - 验证证据输出路径。 * @returns 新建的 VerifyPlan。 * @throws 若 command 格式非法。 */ export function createVerifyPlan( scriptName: string, completionPromiseId: string, gateTraceId: string, options?: { maxAttempts?: number; evidencePath?: string }, ): VerifyPlan { const command = `npm run ${scriptName}`; // 创建时做格式检查 const commandError = validateVerifyCommand(command); if (commandError) { throw new Error(commandError); } return { planId: generatePlanId(), command, scriptName, maxAttempts: options?.maxAttempts ?? 1, completionPromiseId, gateTraceId, evidencePath: options?.evidencePath, }; }