feat: chain plan gate deploy evidence

This commit is contained in:
artale 2026-06-28 12:15:35 +02:00
parent c0d2511279
commit b905b98856
6 changed files with 106 additions and 7 deletions

View File

@ -236,6 +236,7 @@ fable-agent plinius godmode "improve explanation quality"
- `factory deploy <command>` - `factory deploy <command>`
- `--token <token>` - `--token <token>`
- `--gate-receipt <path>` - `--gate-receipt <path>`
- `--plan-receipt <path>`
- `--url <url>` - `--url <url>`
- `--out <path>` - `--out <path>`
- `--yes` - `--yes`
@ -246,6 +247,7 @@ fable-agent plinius godmode "improve explanation quality"
- `--port <n>` - `--port <n>`
- `--capabilities <path>` - `--capabilities <path>`
- `--diagnostic-only` - `--diagnostic-only`
- `--plan-receipt <path>`
- `--run <id>` - `--run <id>`
### `forgejo` ### `forgejo`

View File

@ -28,6 +28,20 @@ function passedGateReceipt(): string {
return file; 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", () => { describe("factory deploy", () => {
it("blocks without explicit approval", async () => { it("blocks without explicit approval", async () => {
const receipt = await deployToFactory({ command: "echo ok", token: "token", gateReceipt: passedGateReceipt() }); 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"); 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 () => { it("marks HTTP failures as not deploy allowed", async () => {
const out = tmpFile(); const out = tmpFile();
const receipt = await deployToFactory({ const receipt = await deployToFactory({

View File

@ -9,6 +9,7 @@ export interface FactoryDeployOptions {
url?: string; url?: string;
yes?: boolean; yes?: boolean;
gateReceipt?: string; gateReceipt?: string;
planReceipt?: string;
out?: string; out?: string;
now?: Date; now?: Date;
fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>; fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>;
@ -24,6 +25,7 @@ export interface FactoryDeployReceipt {
http_status?: number; http_status?: number;
response?: unknown; response?: unknown;
reason?: string; reason?: string;
provenance?: { gateReceipt?: string; plan?: string };
} }
const DEFAULT_URL = CANONICAL_DEPLOY_ROUTE; const DEFAULT_URL = CANONICAL_DEPLOY_ROUTE;
@ -68,8 +70,8 @@ export function isAllowedDeployUrl(url: string): boolean {
return url === DEFAULT_URL; return url === DEFAULT_URL;
} }
export function hasPassedGateReceipt(file: string): boolean { export function hasPassedGateReceipt(file: string, planReceipt?: string): boolean {
return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"] }).ok; return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"], requiredPlanReceipt: planReceipt }).ok;
} }
export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt { 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 (!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 (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence"; 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"; if (!opts.token) return "missing deploy token";
return undefined; return undefined;
} }
function receipt(opts: FactoryDeployOptions, url: string, status: FactoryDeployReceipt["status"], attempted: boolean, allowed: boolean, reason?: string): FactoryDeployReceipt { 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 { function writeMaybe(file: string | undefined, value: FactoryDeployReceipt): FactoryDeployReceipt {

View File

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

View File

@ -43,6 +43,7 @@ export interface GateReceiptOptions {
repo?: string; repo?: string;
task?: string; task?: string;
requiredCommands?: string[]; requiredCommands?: string[];
requiredPlanReceipt?: string;
maxAgeMs?: number; maxAgeMs?: number;
now?: Date; now?: Date;
} }
@ -177,5 +178,22 @@ function invalidReceiptReason(receipt: ZteReceipt, options: GateReceiptOptions):
for (const required of options.requiredCommands ?? ["fable5 verify"]) { for (const required of options.requiredCommands ?? ["fable5 verify"]) {
if (!receipt.commands.some((cmd) => cmd.includes(required))) return `receipt missing command evidence: ${required}`; 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; 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";
}
}

View File

@ -1658,17 +1658,19 @@ factory
.description("Post an approved command to the factory deploy webhook") .description("Post an approved command to the factory deploy webhook")
.option("--token <token>", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN") .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") .requiredOption("--gate-receipt <path>", "ZTE/factory gate receipt JSONL containing a passed status")
.option("--plan-receipt <path>", "Require the gate receipt to reference this plan receipt")
.option("--url <url>", "Deploy route URL", "http://77.42.112.29:8099/deploy") .option("--url <url>", "Deploy route URL", "http://77.42.112.29:8099/deploy")
.option("--out <path>", "Deploy receipt path") .option("--out <path>", "Deploy receipt path")
.option("--yes", "Confirm this is an approved deploy command") .option("--yes", "Confirm this is an approved deploy command")
.option("--run <id>", "Emit deploy receipt event to .runs/<id>/channel.jsonl") .option("--run <id>", "Emit deploy receipt event to .runs/<id>/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 { deployToFactory } = await import("./fable5/factory-deploy.js");
const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`); const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
const receipt = await deployToFactory({ const receipt = await deployToFactory({
command, command,
token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN, token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN,
gateReceipt: opts.gateReceipt, gateReceipt: opts.gateReceipt,
planReceipt: opts.planReceipt,
url: opts.url, url: opts.url,
yes: opts.yes, yes: opts.yes,
out, out,
@ -1687,8 +1689,9 @@ factory
.option("--port <n>", "Factory SSH port for cyber preflight", (v) => Number(v), 2222) .option("--port <n>", "Factory SSH port for cyber preflight", (v) => Number(v), 2222)
.option("--capabilities <path>", "Capability registry path", "factory-capabilities.yaml") .option("--capabilities <path>", "Capability registry path", "factory-capabilities.yaml")
.option("--diagnostic-only", "Bypass live factory/deploy readiness failures for non-deploy diagnostics") .option("--diagnostic-only", "Bypass live factory/deploy readiness failures for non-deploy diagnostics")
.option("--plan-receipt <path>", "Attach a plan receipt path as gate provenance")
.option("--run <id>", "Emit gate events to .runs/<id>/channel.jsonl") .option("--run <id>", "Emit gate events to .runs/<id>/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 { spawnSync } = await import("node:child_process");
const repo = path.resolve(opts.repo ?? "."); const repo = path.resolve(opts.repo ?? ".");
await emitRunEvent(opts.run, { source: "gate", type: "started", data: { kind: "factory-gate", task, 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); process.exit(check.status ?? 1);
} }
const { appendZteReceipt, createZteReceipt } = await import("./fable5/zte-protocol.js"); 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 } }); 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(` ✓ Factory gate passed; deploy still requires explicit token/contract.`);
console.log(` ✓ Gate receipt: ${receiptPath}`); console.log(` ✓ Gate receipt: ${receiptPath}`);