From 9df2929446ea44c8fa3734c734ad3f3e9a97097c Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 01:06:04 +0200 Subject: [PATCH] feat: reconcile cyber preflight with capabilities --- COMMANDS.md | 4 ++ src/fable5/cyber-reconcile.test.ts | 88 ++++++++++++++++++++++++++++++ src/fable5/cyber-reconcile.ts | 62 +++++++++++++++++++++ src/fable5/index.ts | 3 + src/index.ts | 15 +++++ 5 files changed, 172 insertions(+) create mode 100644 src/fable5/cyber-reconcile.test.ts create mode 100644 src/fable5/cyber-reconcile.ts diff --git a/COMMANDS.md b/COMMANDS.md index 7c1a7b9..e0225fa 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -101,6 +101,10 @@ fable-agent plinius godmode "improve explanation quality" - `--port ` - `--mode ` - `--output ` +- `cyber reconcile` + - `--preflight ` + - `--capabilities ` + - `--output ` ### `pai` diff --git a/src/fable5/cyber-reconcile.test.ts b/src/fable5/cyber-reconcile.test.ts new file mode 100644 index 0000000..8d52f15 --- /dev/null +++ b/src/fable5/cyber-reconcile.test.ts @@ -0,0 +1,88 @@ +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 { reconcileCyberPreflight, writeCyberReconcileReceipt } from "./cyber-reconcile.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function tmpRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-cyber-reconcile-")); + roots.push(root); + return root; +} + +function writeInputs(root: string, checks: Array<{ name: string; ok: boolean; status: number | null; stderr?: string }>) { + const preflight = path.join(root, "preflight.json"); + const capabilities = path.join(root, "factory-capabilities.yaml"); + fs.writeFileSync(preflight, JSON.stringify({ + created_at: "2026-06-16T00:00:00.000Z", + host: "127.0.0.1", + port: 2222, + mode: "read-only", + checks: checks.map((check) => ({ command: check.name, stdout: "", stderr: check.stderr ?? "", ...check })), + decision: checks.every((check) => check.ok) ? "allow_read_only" : "fail_closed", + deploy_allowed: false, + reason: "test", + }, null, 2)); + fs.writeFileSync(capabilities, `services: + deploy-webhook: + url: http://127.0.0.1:8098/health + provides: [deploy] + required: true + git-proxy: + url: http://127.0.0.1:8099/factory-status + provides: [factory-status, discovery] + required: true +`); + return { preflight, capabilities }; +} + +describe("cyber reconcile", () => { + it("fails closed when live evidence contradicts required capabilities", () => { + const root = tmpRoot(); + const { preflight, capabilities } = writeInputs(root, [ + { name: "deploy_webhook_health", ok: false, status: 7, stderr: "connection failed" }, + { name: "git_proxy_factory_status", ok: false, status: 22, stderr: "404" }, + { name: "git_proxy_health", ok: false, status: 22, stderr: "404" }, + ]); + + const receipt = reconcileCyberPreflight(preflight, capabilities, new Date("2026-06-16T00:00:00.000Z")); + + expect(receipt.decision).toBe("fail_closed"); + expect(receipt.deploy_allowed).toBe(false); + expect(receipt.findings.map((finding) => finding.status)).toEqual(["missing", "missing", "missing"]); + }); + + it("allows read-only only when all mapped capabilities have evidence", () => { + const root = tmpRoot(); + const { preflight, capabilities } = writeInputs(root, [ + { name: "deploy_webhook_health", ok: true, status: 0 }, + { name: "git_proxy_factory_status", ok: true, status: 0 }, + { name: "git_proxy_health", ok: true, status: 0 }, + ]); + + const receipt = reconcileCyberPreflight(preflight, capabilities); + + expect(receipt.decision).toBe("allow_read_only"); + expect(receipt.deploy_allowed).toBe(false); + }); + + it("writes a reconcile receipt", () => { + const root = tmpRoot(); + const { preflight, capabilities } = writeInputs(root, [ + { name: "deploy_webhook_health", ok: false, status: 7 }, + { name: "git_proxy_factory_status", ok: false, status: 22 }, + { name: "git_proxy_health", ok: false, status: 22 }, + ]); + const out = path.join(root, "reconcile.json"); + + writeCyberReconcileReceipt(out, reconcileCyberPreflight(preflight, capabilities)); + + expect(fs.readFileSync(out, "utf-8")).toContain("fail_closed"); + }); +}); diff --git a/src/fable5/cyber-reconcile.ts b/src/fable5/cyber-reconcile.ts new file mode 100644 index 0000000..bfa49c7 --- /dev/null +++ b/src/fable5/cyber-reconcile.ts @@ -0,0 +1,62 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { loadFactoryCapabilities, type FactoryCapabilityService } from "./factory-capabilities.js"; +import type { CyberPreflightReceipt } from "./cyber-preflight.js"; + +export interface CyberReconcileFinding { + capability: string; + expected: string; + evidence: string; + status: "ok" | "missing"; +} + +export interface CyberReconcileReceipt { + created_at: string; + preflight: string; + capabilities: string; + findings: CyberReconcileFinding[]; + decision: "allow_read_only" | "fail_closed"; + deploy_allowed: false; + recommendation: string; +} + +export function reconcileCyberPreflight(preflightFile: string, capabilitiesFile: string, now = new Date()): CyberReconcileReceipt { + const preflight = JSON.parse(fs.readFileSync(preflightFile, "utf-8")) as CyberPreflightReceipt; + const capabilities = loadFactoryCapabilities(capabilitiesFile); + const findings = capabilities.flatMap((service) => findingsForService(service, preflight)); + const allOk = findings.every((finding) => finding.status === "ok") && preflight.decision === "allow_read_only"; + + return { + created_at: now.toISOString(), + preflight: preflightFile, + capabilities: capabilitiesFile, + findings, + decision: allOk ? "allow_read_only" : "fail_closed", + deploy_allowed: false, + recommendation: allOk ? "read-only posture verified; deploy still requires separate approval" : "hold position; restore telemetry routes or update capability registry", + }; +} + +export function writeCyberReconcileReceipt(file: string, receipt: CyberReconcileReceipt): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); +} + +function findingsForService(service: FactoryCapabilityService, preflight: CyberPreflightReceipt): CyberReconcileFinding[] { + return service.provides.map((capability) => { + const check = matchingCheck(capability, preflight); + return { + capability, + expected: service.url, + evidence: check ? `${check.name}: ${check.ok ? "ok" : `failed status=${check.status} ${check.stderr.trim()}`}` : "no matching preflight check", + status: check?.ok ? "ok" : "missing", + }; + }); +} + +function matchingCheck(capability: string, preflight: CyberPreflightReceipt) { + if (capability === "deploy") return preflight.checks.find((check) => check.name === "deploy_webhook_health"); + if (capability === "factory-status") return preflight.checks.find((check) => check.name === "git_proxy_factory_status"); + if (capability === "discovery") return preflight.checks.find((check) => check.name === "git_proxy_health"); + return undefined; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index b3b789e..a758dc7 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -56,5 +56,8 @@ export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEven export { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js"; export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js"; +export { reconcileCyberPreflight, writeCyberReconcileReceipt } from "./cyber-reconcile.js"; +export type { CyberReconcileFinding, CyberReconcileReceipt } from "./cyber-reconcile.js"; + export { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js"; export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js"; diff --git a/src/index.ts b/src/index.ts index bfd5710..d303469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2828,6 +2828,21 @@ cyber if (receipt.decision !== "allow_read_only") process.exit(1); }); +cyber + .command("reconcile") + .description("Compare cyber preflight evidence against factory capability expectations") + .requiredOption("--preflight ", "Cyber preflight receipt JSON") + .option("--capabilities ", "Capability registry path", "factory-capabilities.yaml") + .option("--output ", "Reconcile receipt output file") + .action(async (opts: { preflight: string; capabilities?: string; output?: string }) => { + const { reconcileCyberPreflight, writeCyberReconcileReceipt } = await import("./fable5/cyber-reconcile.js"); + const receipt = reconcileCyberPreflight(opts.preflight, opts.capabilities ?? "factory-capabilities.yaml"); + if (opts.output) writeCyberReconcileReceipt(opts.output, receipt); + console.log(JSON.stringify(receipt, null, 2)); + if (opts.output) console.log(`\n Receipt: ${opts.output}\n`); + if (receipt.decision !== "allow_read_only") process.exit(1); + }); + security .command("scan ") .description("Scan files for prompt-injection markers")