From 933f3c8974bb1fc9401ae469e57e83249d317ee8 Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 02:59:32 +0200 Subject: [PATCH] fix: require gate receipt for factory deploy --- COMMANDS.md | 1 + src/fable5/factory-deploy.test.ts | 64 ++++++++++++++++++++++++++----- src/fable5/factory-deploy.ts | 46 ++++++++++++++++------ src/index.ts | 4 +- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index 446f463..87e818b 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -172,6 +172,7 @@ fable-agent plinius godmode "improve explanation quality" - `--file ` - `factory deploy ` - `--token ` + - `--gate-receipt ` - `--url ` - `--out ` - `--yes` diff --git a/src/fable5/factory-deploy.test.ts b/src/fable5/factory-deploy.test.ts index ef8c43c..9240035 100644 --- a/src/fable5/factory-deploy.test.ts +++ b/src/fable5/factory-deploy.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { deployToFactory, isAllowedFactoryCommand } from "./factory-deploy.js"; +import { deployToFactory, hasPassedGateReceipt, isAllowedFactoryCommand } from "./factory-deploy.js"; const roots: string[] = []; @@ -10,23 +10,37 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); -function tmpFile(): string { +function tmpFile(name = "receipt.json"): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-factory-deploy-")); roots.push(root); - return path.join(root, "receipt.json"); + return path.join(root, name); +} + +function passedGateReceipt(): string { + const file = tmpFile("zte-receipts.jsonl"); + fs.writeFileSync(file, `${JSON.stringify({ status: "passed", task: "gate" })}\n`); + return file; } describe("factory deploy", () => { it("blocks without explicit approval", async () => { - const receipt = await deployToFactory({ command: "echo ok", token: "token" }); + const receipt = await deployToFactory({ command: "echo ok", token: "token", gateReceipt: passedGateReceipt() }); expect(receipt.status).toBe("blocked"); expect(receipt.deploy_attempted).toBe(false); expect(receipt.reason).toContain("--yes"); }); + it("blocks without a passed gate receipt", async () => { + const receipt = await deployToFactory({ command: "echo ok", token: "token", yes: true }); + + expect(receipt.status).toBe("blocked"); + expect(receipt.deploy_attempted).toBe(false); + expect(receipt.reason).toContain("gate-receipt"); + }); + it("blocks unsafe command prefixes", async () => { - const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true }); + const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true, gateReceipt: passedGateReceipt() }); expect(receipt.status).toBe("blocked"); expect(receipt.reason).toContain("command must start"); @@ -34,12 +48,13 @@ describe("factory deploy", () => { expect(isAllowedFactoryCommand("rm -rf /")).toBe(false); }); - it("posts allowed commands with token", async () => { + it("posts allowed commands with token and gate receipt", async () => { const calls: unknown[] = []; const receipt = await deployToFactory({ command: "echo ok", token: "token", yes: true, + gateReceipt: passedGateReceipt(), fetcher: async (_url, init) => { calls.push(init); return { status: 200, json: async () => ({ exit_code: 0 }), text: async () => "" }; @@ -52,10 +67,41 @@ describe("factory deploy", () => { expect(JSON.stringify(calls[0])).toContain("Bearer token"); }); - it("writes a receipt", async () => { + it("marks HTTP failures as not deploy allowed", async () => { const out = tmpFile(); - await deployToFactory({ command: "echo ok", out }); + const receipt = await deployToFactory({ + command: "echo ok", + token: "token", + yes: true, + gateReceipt: passedGateReceipt(), + out, + fetcher: async () => ({ status: 500, json: async () => ({ error: "nope" }), text: async () => "" }), + }); - expect(fs.readFileSync(out, "utf-8")).toContain("missing --yes"); + expect(receipt.status).toBe("failed"); + expect(receipt.deploy_attempted).toBe(true); + expect(receipt.deploy_allowed).toBe(false); + expect(fs.readFileSync(out, "utf-8")).toContain("HTTP 500"); + }); + + it("writes a failed receipt when fetch throws", async () => { + const out = tmpFile(); + const receipt = await deployToFactory({ + command: "echo ok", + token: "token", + yes: true, + gateReceipt: passedGateReceipt(), + out, + fetcher: async () => { throw new Error("network down"); }, + }); + + expect(receipt.status).toBe("failed"); + expect(receipt.deploy_allowed).toBe(false); + expect(fs.readFileSync(out, "utf-8")).toContain("network down"); + }); + + it("detects passed JSONL gate receipts", () => { + expect(hasPassedGateReceipt(passedGateReceipt())).toBe(true); + expect(hasPassedGateReceipt(tmpFile("missing.jsonl"))).toBe(false); }); }); diff --git a/src/fable5/factory-deploy.ts b/src/fable5/factory-deploy.ts index e42ad7d..5b9de2a 100644 --- a/src/fable5/factory-deploy.ts +++ b/src/fable5/factory-deploy.ts @@ -6,6 +6,7 @@ export interface FactoryDeployOptions { token?: string; url?: string; yes?: boolean; + gateReceipt?: string; out?: string; now?: Date; fetcher?: (url: string, init: { method: string; headers: Record; body: string }) => Promise<{ status: number; json(): Promise; text(): Promise }>; @@ -31,23 +32,44 @@ export async function deployToFactory(opts: FactoryDeployOptions): Promise= 200 && res.status < 300 ? "posted" : "failed", true, true), - http_status: res.status, - response: body, - }); + try { + const res = await (opts.fetcher ?? fetchJson)(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` }, + body: JSON.stringify({ command: opts.command }), + }); + const body = await readBody(res); + const posted = res.status >= 200 && res.status < 300; + return writeMaybe(opts.out, { + ...receipt(opts, url, posted ? "posted" : "failed", true, posted, posted ? undefined : `HTTP ${res.status}`), + http_status: res.status, + response: body, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return writeMaybe(opts.out, receipt(opts, url, "failed", false, false, reason)); + } } export function isAllowedFactoryCommand(command: string): boolean { return ALLOWED_PREFIXES.some((prefix) => command.trim().startsWith(prefix)); } +export function hasPassedGateReceipt(file: string): boolean { + if (!fs.existsSync(file)) return false; + // ponytail: accept JSON or JSONL; ZTE currently appends JSONL receipts. + return fs.readFileSync(file, "utf-8") + .split(/\r?\n/) + .filter(Boolean) + .some((line) => { + try { + return JSON.parse(line).status === "passed"; + } catch { + return false; + } + }); +} + export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); @@ -56,6 +78,8 @@ export function writeFactoryDeployReceipt(file: string, value: FactoryDeployRece function blockReason(opts: FactoryDeployOptions): string | undefined { if (!opts.yes) return "missing --yes human approval"; + if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence"; + if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed status"; if (!opts.token) return "missing deploy token"; if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`; return undefined; diff --git a/src/index.ts b/src/index.ts index 9de9f9e..8f4ee86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1456,15 +1456,17 @@ factory .command("deploy ") .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("--url ", "Deploy webhook URL", "http://77.42.112.29:8099/deploy") .option("--out ", "Deploy receipt path") .option("--yes", "Confirm this is an approved deploy command") - .action(async (command: string, opts: { token?: string; url?: string; out?: string; yes?: boolean }) => { + .action(async (command: string, opts: { token?: string; gateReceipt: string; url?: string; out?: string; yes?: boolean }) => { 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, url: opts.url, yes: opts.yes, out,