diff --git a/src/fable5/factory-deploy.test.ts b/src/fable5/factory-deploy.test.ts index 9240035..2699466 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, hasPassedGateReceipt, isAllowedFactoryCommand } from "./factory-deploy.js"; +import { deployToFactory, hasPassedGateReceipt, isAllowedDeployUrl, isAllowedFactoryCommand } from "./factory-deploy.js"; const roots: string[] = []; @@ -18,7 +18,13 @@ function tmpFile(name = "receipt.json"): string { function passedGateReceipt(): string { const file = tmpFile("zte-receipts.jsonl"); - fs.writeFileSync(file, `${JSON.stringify({ status: "passed", task: "gate" })}\n`); + fs.writeFileSync(file, `${JSON.stringify({ + status: "passed", + task: "gate", + repo: ".", + commands: ["fable-agent fable5 verify repo gate --repo ."], + createdAt: "2026-06-23T00:00:00.000Z", + })}\n`); return file; } @@ -31,7 +37,7 @@ describe("factory deploy", () => { expect(receipt.reason).toContain("--yes"); }); - it("blocks without a passed gate receipt", async () => { + it("blocks without a passed verifier receipt", async () => { const receipt = await deployToFactory({ command: "echo ok", token: "token", yes: true }); expect(receipt.status).toBe("blocked"); @@ -39,12 +45,34 @@ describe("factory deploy", () => { expect(receipt.reason).toContain("gate-receipt"); }); + it("blocks arbitrary deploy URLs before sending the token", async () => { + const calls: unknown[] = []; + const receipt = await deployToFactory({ + command: "echo ok", + token: "token", + yes: true, + gateReceipt: passedGateReceipt(), + url: "https://attacker.test/deploy", + fetcher: async (_url, init) => { + calls.push(init); + return { status: 200, json: async () => ({}), text: async () => "" }; + }, + }); + + expect(receipt.status).toBe("blocked"); + expect(receipt.deploy_attempted).toBe(false); + expect(receipt.reason).toContain("guarded 8099"); + expect(calls).toHaveLength(0); + expect(isAllowedDeployUrl("https://attacker.test/deploy")).toBe(false); + }); + it("blocks unsafe command prefixes", async () => { 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"); + expect(receipt.reason).toContain("allowlist"); expect(isAllowedFactoryCommand("docker ps")).toBe(true); + expect(isAllowedFactoryCommand("docker ps; rm -rf /")).toBe(false); expect(isAllowedFactoryCommand("rm -rf /")).toBe(false); }); @@ -100,8 +128,11 @@ describe("factory deploy", () => { expect(fs.readFileSync(out, "utf-8")).toContain("network down"); }); - it("detects passed JSONL gate receipts", () => { + it("detects passed JSONL verifier receipts only", () => { expect(hasPassedGateReceipt(passedGateReceipt())).toBe(true); + const forged = tmpFile("forged.jsonl"); + fs.writeFileSync(forged, `${JSON.stringify({ status: "passed", task: "gate" })}\n`); + expect(hasPassedGateReceipt(forged)).toBe(false); expect(hasPassedGateReceipt(tmpFile("missing.jsonl"))).toBe(false); }); }); diff --git a/src/fable5/factory-deploy.ts b/src/fable5/factory-deploy.ts index 5b9de2a..887a180 100644 --- a/src/fable5/factory-deploy.ts +++ b/src/fable5/factory-deploy.ts @@ -25,11 +25,18 @@ export interface FactoryDeployReceipt { } const DEFAULT_URL = "http://77.42.112.29:8099/deploy"; -const ALLOWED_PREFIXES = ["docker ", "echo ", "curl ", "cat "]; +const SAFE_COMMANDS = [ + /^echo [A-Za-z0-9 _.,:;@/+\-=]+$/, + /^cat \/tmp\/[A-Za-z0-9._/-]+$/, + /^docker ps(?: --format '[A-Za-z0-9 {}_.:;@/+\-=|]+')?$/, + /^docker logs --tail \d{1,4} [A-Za-z0-9_.-]+$/, + /^docker inspect [A-Za-z0-9_.-]+$/, + /^curl -fsS -m \d{1,2} http:\/\/127\.0\.0\.1:8099\/(?:health|factory-status)$/, +]; export async function deployToFactory(opts: FactoryDeployOptions): Promise { const url = opts.url ?? DEFAULT_URL; - const blocked = blockReason(opts); + const blocked = blockReason(opts, url); if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked)); try { @@ -52,18 +59,28 @@ export async function deployToFactory(opts: FactoryDeployOptions): Promise command.trim().startsWith(prefix)); + return SAFE_COMMANDS.some((pattern) => pattern.test(command.trim())); +} + +export function isAllowedDeployUrl(url: string): boolean { + return url === DEFAULT_URL; } 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"; + const json = JSON.parse(line) as { status?: unknown; task?: unknown; repo?: unknown; commands?: unknown; createdAt?: unknown }; + return json.status === "passed" + && typeof json.task === "string" + && typeof json.repo === "string" + && Array.isArray(json.commands) + && json.commands.some((cmd) => typeof cmd === "string" && cmd.includes("fable5 verify")) + && typeof json.createdAt === "string" + && Number.isFinite(Date.parse(json.createdAt)); } catch { return false; } @@ -76,12 +93,13 @@ export function writeFactoryDeployReceipt(file: string, value: FactoryDeployRece return value; } -function blockReason(opts: FactoryDeployOptions): string | undefined { +function blockReason(opts: FactoryDeployOptions, url: string): string | undefined { if (!opts.yes) return "missing --yes human approval"; + if (!isAllowedDeployUrl(url)) return "deploy url must be the guarded 8099 git-proxy route"; if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence"; - if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed status"; + if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed verifier status"; if (!opts.token) return "missing deploy token"; - if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`; + if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist"; return undefined; }