diff --git a/COMMANDS.md b/COMMANDS.md index a68a6e8..7c1a7b9 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -27,6 +27,7 @@ fable-agent plinius godmode "improve explanation quality" | `skills` | Skill registry ops | | `state` | Knowledge state / stats view | | `security` | Security scanners | +| `cyber` | Read-only defensive cyber evidence collection | | `pai` | PAI/bridge operations | | `status` | Runtime surface summary | | `workflow` | Load workflow JSON | @@ -93,6 +94,14 @@ fable-agent plinius godmode "improve explanation quality" - `security scan `: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers - `--include-fixtures`: include intentional red-team fixtures/generators +### `cyber` + +- `cyber preflight` + - `--host ` + - `--port ` + - `--mode ` + - `--output ` + ### `pai` - `pai status` diff --git a/src/fable5/cyber-preflight.test.ts b/src/fable5/cyber-preflight.test.ts new file mode 100644 index 0000000..b741eaa --- /dev/null +++ b/src/fable5/cyber-preflight.test.ts @@ -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"); + }); +}); diff --git a/src/fable5/cyber-preflight.ts b/src/fable5/cyber-preflight.ts new file mode 100644 index 0000000..6174074 --- /dev/null +++ b/src/fable5/cyber-preflight.ts @@ -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): 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]"); +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index a41d361..b3b789e 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -53,5 +53,8 @@ export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js"; export { channelPath, emitChannelEvent, readChannelEvents } 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 type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js"; diff --git a/src/index.ts b/src/index.ts index ebf558e..bfd5710 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2802,6 +2802,32 @@ const security = program .command("security") .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 ", "Target host to inspect over SSH") + .option("--port ", "SSH port", (v) => Number(v), 2222) + .option("--mode ", "Only read-only is supported", "read-only") + .option("--output ", "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 .command("scan ") .description("Scan files for prompt-injection markers")