feat: reconcile cyber preflight with capabilities
This commit is contained in:
parent
afc349ee59
commit
9df2929446
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
15
src/index.ts
15
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 <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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue