feat: chain plan gate deploy evidence
This commit is contained in:
parent
c0d2511279
commit
b905b98856
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue