feat: reconcile cyber preflight with capabilities

This commit is contained in:
artale 2026-06-18 01:06:04 +02:00
parent afc349ee59
commit 9df2929446
5 changed files with 172 additions and 0 deletions

View File

@ -101,6 +101,10 @@ fable-agent plinius godmode "improve explanation quality"
- `--port <n>`
- `--mode <mode>`
- `--output <file>`
- `cyber reconcile`
- `--preflight <file>`
- `--capabilities <file>`
- `--output <file>`
### `pai`

View File

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

View File

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

View File

@ -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";

View File

@ -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 <file>", "Cyber preflight receipt JSON")
.option("--capabilities <file>", "Capability registry path", "factory-capabilities.yaml")
.option("--output <file>", "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 <target>")
.description("Scan files for prompt-injection markers")