feat: add cyber preflight receipts

This commit is contained in:
artale 2026-06-18 00:49:19 +02:00
parent 419881acbd
commit afc349ee59
5 changed files with 186 additions and 0 deletions

View File

@ -27,6 +27,7 @@ fable-agent plinius godmode "improve explanation quality"
| `skills` | Skill registry ops | | `skills` | Skill registry ops |
| `state` | Knowledge state / stats view | | `state` | Knowledge state / stats view |
| `security` | Security scanners | | `security` | Security scanners |
| `cyber` | Read-only defensive cyber evidence collection |
| `pai` | PAI/bridge operations | | `pai` | PAI/bridge operations |
| `status` | Runtime surface summary | | `status` | Runtime surface summary |
| `workflow` | Load workflow JSON | | `workflow` | Load workflow JSON |
@ -93,6 +94,14 @@ fable-agent plinius godmode "improve explanation quality"
- `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers - `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers
- `--include-fixtures`: include intentional red-team fixtures/generators - `--include-fixtures`: include intentional red-team fixtures/generators
### `cyber`
- `cyber preflight`
- `--host <host>`
- `--port <n>`
- `--mode <mode>`
- `--output <file>`
### `pai` ### `pai`
- `pai status` - `pai status`

View File

@ -0,0 +1,55 @@
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 { runCyberPreflight } from "./cyber-preflight.js";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
function tmpFile(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-cyber-preflight-"));
roots.push(root);
return path.join(root, "receipt.json");
}
function runner(failNames: string[] = []) {
return (_command: string, args: string[]) => {
const joined = args.join(" ");
const shouldFail = failNames.some((name) => joined.includes(name));
return {
status: shouldFail ? 22 : 0,
stdout: shouldFail ? "" : "ok token=SHOULD_REDACT",
stderr: shouldFail ? "curl: returned 404 Authorization: token SHOULD_REDACT" : "",
};
};
}
describe("cyber preflight", () => {
it("allows read-only when required live checks pass", () => {
const receipt = runCyberPreflight({ host: "127.0.0.1", runner: runner(), now: new Date("2026-06-16T00:00:00.000Z") });
expect(receipt.decision).toBe("allow_read_only");
expect(receipt.deploy_allowed).toBe(false);
expect(receipt.checks).toHaveLength(5);
});
it("fails closed when a required endpoint is missing", () => {
const receipt = runCyberPreflight({ host: "127.0.0.1", runner: runner(["factory-status"]) });
expect(receipt.decision).toBe("fail_closed");
expect(receipt.deploy_allowed).toBe(false);
});
it("writes a redacted receipt", () => {
const out = tmpFile();
runCyberPreflight({ host: "127.0.0.1", out, runner: runner(["health"]) });
const text = fs.readFileSync(out, "utf-8");
expect(text).toContain("[REDACTED]");
expect(text).not.toContain("SHOULD_REDACT");
});
});

View File

@ -0,0 +1,93 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
export interface CyberPreflightCheck {
name: string;
command: string;
ok: boolean;
status: number | null;
stdout: string;
stderr: string;
}
export interface CyberPreflightReceipt {
created_at: string;
host: string;
port: number;
mode: "read-only";
checks: CyberPreflightCheck[];
decision: "allow_read_only" | "fail_closed";
deploy_allowed: false;
reason: string;
}
export interface CyberPreflightOptions {
host: string;
port?: number;
out?: string;
now?: Date;
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
}
const REQUIRED_ENDPOINTS = [
["git_proxy_health", "http://127.0.0.1:8099/health"],
["git_proxy_factory_status", "http://127.0.0.1:8099/factory-status"],
["deploy_webhook_health", "http://127.0.0.1:8098/health"],
] as const;
export function runCyberPreflight(opts: CyberPreflightOptions): CyberPreflightReceipt {
const port = opts.port ?? 2222;
const runner = opts.runner ?? runCommand;
const checks: CyberPreflightCheck[] = [];
checks.push(runCheck("ssh_reachable", "ssh", ["-p", String(port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", `root@${opts.host}`, "hostname"], runner));
checks.push(runCheck("docker_inventory", "ssh", ["-p", String(port), `root@${opts.host}`, "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"], runner));
for (const [name, url] of REQUIRED_ENDPOINTS) {
checks.push(runCheck(name, "ssh", ["-p", String(port), `root@${opts.host}`, `curl -fsS -m 5 ${url}`], runner));
}
const requiredOk = checks.filter((c) => c.name !== "docker_inventory").every((c) => c.ok);
const receipt: CyberPreflightReceipt = {
created_at: (opts.now ?? new Date()).toISOString(),
host: opts.host,
port,
mode: "read-only",
checks,
decision: requiredOk ? "allow_read_only" : "fail_closed",
deploy_allowed: false,
reason: requiredOk ? "read-only evidence checks passed" : "required live evidence missing or unhealthy",
};
if (opts.out) writeCyberPreflightReceipt(opts.out, receipt);
return receipt;
}
export function writeCyberPreflightReceipt(file: string, receipt: CyberPreflightReceipt): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
}
function runCheck(name: string, command: string, args: string[], runner: NonNullable<CyberPreflightOptions["runner"]>): CyberPreflightCheck {
const result = runner(command, args);
return {
name,
command: [command, ...args].join(" "),
ok: result.status === 0,
status: result.status,
stdout: redact(result.stdout).slice(0, 4000),
stderr: redact(result.stderr).slice(0, 4000),
};
}
function runCommand(command: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync(command, args, { encoding: "utf-8" });
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
function redact(value: string): string {
return value
.replace(/(Authorization:\s*(?:Bearer|token)\s+)[^\s]+/gi, "$1[REDACTED]")
.replace(/((?:token|secret|password|api[_-]?key)=)[^\s]+/gi, "$1[REDACTED]");
}

View File

@ -53,5 +53,8 @@ export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";
export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js";
export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js";
export { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js";
export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js";
export { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js"; export { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js";
export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js"; export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js";

View File

@ -2802,6 +2802,32 @@ const security = program
.command("security") .command("security")
.description("Security scanners"); .description("Security scanners");
// ── Cyber ───────────────────────────────────────────────────
const cyber = program
.command("cyber")
.description("Read-only defensive cyber evidence collection");
cyber
.command("preflight")
.description("Collect read-only factory evidence and write a fail-closed receipt")
.requiredOption("--host <host>", "Target host to inspect over SSH")
.option("--port <n>", "SSH port", (v) => Number(v), 2222)
.option("--mode <mode>", "Only read-only is supported", "read-only")
.option("--output <file>", "Receipt output file")
.action(async (opts: { host: string; port?: number; mode?: string; output?: string }) => {
if (opts.mode !== "read-only") {
console.error(" ✗ cyber preflight only supports --mode read-only");
process.exit(1);
}
const { runCyberPreflight } = await import("./fable5/cyber-preflight.js");
const out = opts.output ?? path.join(".fable", "cyber-preflight", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
const receipt = runCyberPreflight({ host: opts.host, port: opts.port, out });
console.log(JSON.stringify(receipt, null, 2));
console.log(`\n Receipt: ${out}\n`);
if (receipt.decision !== "allow_read_only") process.exit(1);
});
security security
.command("scan <target>") .command("scan <target>")
.description("Scan files for prompt-injection markers") .description("Scan files for prompt-injection markers")