diff --git a/COMMANDS.md b/COMMANDS.md index f4001fe..4031787 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -236,6 +236,7 @@ fable-agent plinius godmode "improve explanation quality" - `factory deploy ` - `--token ` - `--gate-receipt ` + - `--plan-receipt ` - `--url ` - `--out ` - `--yes` @@ -246,6 +247,7 @@ fable-agent plinius godmode "improve explanation quality" - `--port ` - `--capabilities ` - `--diagnostic-only` + - `--plan-receipt ` - `--run ` ### `forgejo` diff --git a/src/fable5/factory-deploy.test.ts b/src/fable5/factory-deploy.test.ts index c6620cf..fb4bfe8 100644 --- a/src/fable5/factory-deploy.test.ts +++ b/src/fable5/factory-deploy.test.ts @@ -28,6 +28,20 @@ function passedGateReceipt(): string { return file; } +function passedGateReceiptWithPlan(plan: string): string { + const file = tmpFile("zte-plan-receipts.jsonl"); + fs.writeFileSync(file, `${JSON.stringify({ + status: "passed", + task: "gate", + repo: ".", + commands: ["fable-agent factory gate gate --repo .", "fable-agent fable5 verify repo gate --repo ."], + provenance: { plan }, + createdAt: "2026-06-23T00:00:00.000Z", + })} +`); + return file; +} + describe("factory deploy", () => { it("blocks without explicit approval", async () => { const receipt = await deployToFactory({ command: "echo ok", token: "token", gateReceipt: passedGateReceipt() }); @@ -95,6 +109,26 @@ describe("factory deploy", () => { expect(JSON.stringify(calls[0])).toContain("Bearer token"); }); + + it("threads plan provenance from gate receipt into deploy receipts", async () => { + const plan = tmpFile("plan.json"); + fs.writeFileSync(plan, `${JSON.stringify({ schema: "fable.plan.receipt.v1", decision: "ready" })} +`); + const gateReceipt = passedGateReceiptWithPlan(plan); + const receipt = await deployToFactory({ + command: "echo ok", + token: "token", + yes: true, + gateReceipt, + planReceipt: plan, + fetcher: async () => ({ status: 200, json: async () => ({ exit_code: 0 }), text: async () => "" }), + }); + + expect(receipt.status).toBe("posted"); + expect(receipt.provenance).toEqual({ gateReceipt, plan }); + expect(hasPassedGateReceipt(gateReceipt, plan)).toBe(true); + }); + it("marks HTTP failures as not deploy allowed", async () => { const out = tmpFile(); const receipt = await deployToFactory({ diff --git a/src/fable5/factory-deploy.ts b/src/fable5/factory-deploy.ts index 0c0dae5..731e0de 100644 --- a/src/fable5/factory-deploy.ts +++ b/src/fable5/factory-deploy.ts @@ -9,6 +9,7 @@ export interface FactoryDeployOptions { url?: string; yes?: boolean; gateReceipt?: string; + planReceipt?: string; out?: string; now?: Date; fetcher?: (url: string, init: { method: string; headers: Record; body: string }) => Promise<{ status: number; json(): Promise; text(): Promise }>; @@ -24,6 +25,7 @@ export interface FactoryDeployReceipt { http_status?: number; response?: unknown; reason?: string; + provenance?: { gateReceipt?: string; plan?: string }; } const DEFAULT_URL = CANONICAL_DEPLOY_ROUTE; @@ -68,8 +70,8 @@ export function isAllowedDeployUrl(url: string): boolean { return url === DEFAULT_URL; } -export function hasPassedGateReceipt(file: string): boolean { - return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"] }).ok; +export function hasPassedGateReceipt(file: string, planReceipt?: string): boolean { + return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"], requiredPlanReceipt: planReceipt }).ok; } export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt { @@ -83,13 +85,28 @@ function blockReason(opts: FactoryDeployOptions, url: string): string | undefine if (!isAllowedDeployUrl(url)) return "deploy url must be the guarded 8099 git-proxy route"; if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist"; if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence"; - if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed verifier status"; + if (!hasPassedGateReceipt(opts.gateReceipt, opts.planReceipt)) return "gate receipt missing passed verifier status"; if (!opts.token) return "missing deploy token"; return undefined; } function receipt(opts: FactoryDeployOptions, url: string, status: FactoryDeployReceipt["status"], attempted: boolean, allowed: boolean, reason?: string): FactoryDeployReceipt { - return { created_at: (opts.now ?? new Date()).toISOString(), url, command: opts.command, deploy_attempted: attempted, deploy_allowed: allowed, status, reason }; + return { + created_at: (opts.now ?? new Date()).toISOString(), + url, + command: opts.command, + deploy_attempted: attempted, + deploy_allowed: allowed, + status, + reason, + provenance: deployProvenance(opts), + }; +} + +function deployProvenance(opts: FactoryDeployOptions): FactoryDeployReceipt["provenance"] { + if (!opts.gateReceipt) return undefined; + const validation = validateGateReceipt(opts.gateReceipt, { requiredCommands: ["factory gate", "fable5 verify"] }); + return { gateReceipt: opts.gateReceipt, plan: validation.receipt?.provenance?.plan }; } function writeMaybe(file: string | undefined, value: FactoryDeployReceipt): FactoryDeployReceipt { diff --git a/src/fable5/zte-protocol.test.ts b/src/fable5/zte-protocol.test.ts index 1b29b9e..b2ed1e3 100644 --- a/src/fable5/zte-protocol.test.ts +++ b/src/fable5/zte-protocol.test.ts @@ -60,4 +60,29 @@ describe("ZTE protocol", () => { expect(validateGateReceipt(file, { maxAgeMs: 1, now: new Date("2026-06-23T00:01:00.000Z") }).ok).toBe(false); expect(validateGateReceipt(file, { requiredCommands: ["missing"], now: new Date("2026-06-23T00:01:00.000Z") }).ok).toBe(false); }); + + it("requires matching usable plan provenance when requested", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zte-plan-")); + const plan = path.join(dir, "plan.json"); + const file = path.join(dir, "receipts.jsonl"); + fs.writeFileSync(plan, `${JSON.stringify({ schema: "fable.plan.receipt.v1", decision: "ready" })} +`); + fs.writeFileSync(file, `${JSON.stringify({ + schema: "fable.zte.receipt.v1", + task: "deploy", + repo: dir, + status: "passed", + commands: ["factory gate", "fable5 verify"], + provenance: { plan }, + createdAt: "2026-06-23T00:00:00.000Z", + })} +`); + + expect(validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"], requiredPlanReceipt: plan, now: new Date("2026-06-23T00:01:00.000Z") }).ok).toBe(true); + expect(validateGateReceipt(file, { requiredPlanReceipt: path.join(dir, "other.json"), now: new Date("2026-06-23T00:01:00.000Z") }).ok).toBe(false); + fs.writeFileSync(plan, `${JSON.stringify({ schema: "fable.plan.receipt.v1", decision: "blocked" })} +`); + expect(validateGateReceipt(file, { requiredPlanReceipt: plan, now: new Date("2026-06-23T00:01:00.000Z") }).ok).toBe(false); + }); + }); diff --git a/src/fable5/zte-protocol.ts b/src/fable5/zte-protocol.ts index 23ea19c..e64bf37 100644 --- a/src/fable5/zte-protocol.ts +++ b/src/fable5/zte-protocol.ts @@ -43,6 +43,7 @@ export interface GateReceiptOptions { repo?: string; task?: string; requiredCommands?: string[]; + requiredPlanReceipt?: string; maxAgeMs?: number; now?: Date; } @@ -177,5 +178,22 @@ function invalidReceiptReason(receipt: ZteReceipt, options: GateReceiptOptions): for (const required of options.requiredCommands ?? ["fable5 verify"]) { if (!receipt.commands.some((cmd) => cmd.includes(required))) return `receipt missing command evidence: ${required}`; } + if (options.requiredPlanReceipt) { + if (receipt.provenance?.plan !== options.requiredPlanReceipt) return "receipt plan provenance mismatch"; + const planReason = invalidPlanReceiptReason(options.requiredPlanReceipt); + if (planReason) return planReason; + } return undefined; } + +function invalidPlanReceiptReason(file: string): string | undefined { + if (!fs.existsSync(file)) return "plan receipt file missing"; + try { + const value = JSON.parse(fs.readFileSync(file, "utf-8")) as { schema?: unknown; decision?: unknown }; + if (value.schema !== "fable.plan.receipt.v1") return "plan receipt schema invalid"; + if (value.decision === "blocked") return "plan receipt is blocked"; + return undefined; + } catch { + return "plan receipt JSON invalid"; + } +} diff --git a/src/index.ts b/src/index.ts index 7e88664..445d806 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1658,17 +1658,19 @@ factory .description("Post an approved command to the factory deploy webhook") .option("--token ", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN") .requiredOption("--gate-receipt ", "ZTE/factory gate receipt JSONL containing a passed status") + .option("--plan-receipt ", "Require the gate receipt to reference this plan receipt") .option("--url ", "Deploy route URL", "http://77.42.112.29:8099/deploy") .option("--out ", "Deploy receipt path") .option("--yes", "Confirm this is an approved deploy command") .option("--run ", "Emit deploy receipt event to .runs//channel.jsonl") - .action(async (command: string, opts: { token?: string; gateReceipt: string; url?: string; out?: string; yes?: boolean; run?: string }) => { + .action(async (command: string, opts: { token?: string; gateReceipt: string; planReceipt?: string; url?: string; out?: string; yes?: boolean; run?: string }) => { const { deployToFactory } = await import("./fable5/factory-deploy.js"); const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`); const receipt = await deployToFactory({ command, token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN, gateReceipt: opts.gateReceipt, + planReceipt: opts.planReceipt, url: opts.url, yes: opts.yes, out, @@ -1687,8 +1689,9 @@ factory .option("--port ", "Factory SSH port for cyber preflight", (v) => Number(v), 2222) .option("--capabilities ", "Capability registry path", "factory-capabilities.yaml") .option("--diagnostic-only", "Bypass live factory/deploy readiness failures for non-deploy diagnostics") + .option("--plan-receipt ", "Attach a plan receipt path as gate provenance") .option("--run ", "Emit gate events to .runs//channel.jsonl") - .action(async (task: string, opts: { repo?: string; host?: string; port?: number; capabilities?: string; diagnosticOnly?: boolean; run?: string }) => { + .action(async (task: string, opts: { repo?: string; host?: string; port?: number; capabilities?: string; diagnosticOnly?: boolean; planReceipt?: string; run?: string }) => { const { spawnSync } = await import("node:child_process"); const repo = path.resolve(opts.repo ?? "."); await emitRunEvent(opts.run, { source: "gate", type: "started", data: { kind: "factory-gate", task, repo } }); @@ -1747,7 +1750,7 @@ factory process.exit(check.status ?? 1); } const { appendZteReceipt, createZteReceipt } = await import("./fable5/zte-protocol.js"); - const receiptPath = appendZteReceipt(repo, createZteReceipt(task, repo, "passed", ["factory gate", "factory check", "factory capabilities", "cyber preflight", "cyber reconcile", "fable5 verify"])); + const receiptPath = appendZteReceipt(repo, createZteReceipt(task, repo, "passed", ["factory gate", "factory check", "factory capabilities", "cyber preflight", "cyber reconcile", "fable5 verify"], undefined, "review", opts.planReceipt ? { plan: opts.planReceipt } : undefined)); await emitRunEvent(opts.run, { source: "gate", type: "done", data: { kind: "factory-gate", task, repo, receiptPath } }); console.log(` ✓ Factory gate passed; deploy still requires explicit token/contract.`); console.log(` ✓ Gate receipt: ${receiptPath}`);