fix: require gate receipt for factory deploy

This commit is contained in:
artale 2026-06-18 02:59:32 +02:00
parent 4215ac94ae
commit 933f3c8974
4 changed files with 94 additions and 21 deletions

View File

@ -172,6 +172,7 @@ fable-agent plinius godmode "improve explanation quality"
- `--file <path>`
- `factory deploy <command>`
- `--token <token>`
- `--gate-receipt <path>`
- `--url <url>`
- `--out <path>`
- `--yes`

View File

@ -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);
});
});

View File

@ -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<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>;
@ -31,23 +32,44 @@ export async function deployToFactory(opts: FactoryDeployOptions): Promise<Facto
const blocked = blockReason(opts);
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
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);
return writeMaybe(opts.out, {
...receipt(opts, url, res.status >= 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;

View File

@ -1456,15 +1456,17 @@ factory
.command("deploy <command>")
.description("Post an approved command to the factory deploy webhook")
.option("--token <token>", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN")
.requiredOption("--gate-receipt <path>", "ZTE/factory gate receipt JSONL containing a passed status")
.option("--url <url>", "Deploy webhook URL", "http://77.42.112.29:8099/deploy")
.option("--out <path>", "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,