From dfb21fd55553673bfc84130ac790e1cde82a6729 Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Tue, 26 May 2026 17:18:14 -0300 Subject: [PATCH] feat(inquisitor): bridge KCode to Inquisitor for CVE-grade evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KCode owns the open-source discovery → verify → fix pipeline. The final-mile work — standalone reproducer synthesis, adversarial binary scan, signed disclosure bundle, intake submission — moves to Inquisitor, our paid sister service. KCode talks to it over HTTP through this bridge. New module: src/integrations/inquisitor.ts bridge client + preflight + errors New commands (all require Inquisitor account): kcode reproduce standalone compilable reproducer (Mender) kcode validate adversarial-input binary scan (VulnHunter) kcode bundle signed disclosure bundle kcode disclose submit bundle to intake (GHSA/Bugcrowd/email/PR) Configuration via env: INQUISITOR_URL default https://api.astrolexis.space/inquisitor/v1 INQUISITOR_TOKEN_FILE default ~/.inquisitor/token INQUISITOR_TOKEN env override of the token file UX when no token / unreachable: clear upsell message with signup link. Existing kcode commands (audit, --print, agentic fix) remain fully open and free; only the new commands are gated. Signed-off-by: GaltRanch --- README.md | 42 +++++++ src/cli/commands/bundle.ts | 141 ++++++++++++++++++++++ src/cli/commands/disclose.ts | 90 ++++++++++++++ src/cli/commands/index.ts | 4 + src/cli/commands/reproduce.ts | 97 +++++++++++++++ src/cli/commands/validate.ts | 87 ++++++++++++++ src/index.ts | 12 ++ src/integrations/inquisitor.ts | 207 +++++++++++++++++++++++++++++++++ 8 files changed, 680 insertions(+) create mode 100644 src/cli/commands/bundle.ts create mode 100644 src/cli/commands/disclose.ts create mode 100644 src/cli/commands/reproduce.ts create mode 100644 src/cli/commands/validate.ts create mode 100644 src/integrations/inquisitor.ts diff --git a/README.md b/README.md index feb5b0e..bed0a6d 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,48 @@ Use `/plugins` to list installed plugins. --- +## From candidates to CVE-grade evidence (Inquisitor) + +KCode ships the full audit-and-fix pipeline as open source — discovery, +LLM verification, agentic re-verification, and agentic fix generation. +For most users that's enough: open a PR with a verified patch, done. + +A small number of users need to go further: standalone reproducers, +binary scans that actually validate the finding, signed disclosure +bundles ready for a GitHub Security Advisory or a Bugcrowd submission. +That last mile is handled by [Inquisitor][inq], our paid sister +service. Inquisitor's daemon does the work behind a service boundary; +KCode talks to it over HTTP. + +[inq]: https://astrolexis.space/inquisitor + +| Command | Open / Paid | Action | +| ------------------ | :---------: | ----------------------------------------------- | +| `kcode audit` | Open | Discovery + LLM verifier → confirmed findings | +| `kcode --print …` | Open | Agentic re-verify and fix generation | +| `kcode reproduce` | **Inq.** | Standalone compilable reproducer (Mender) | +| `kcode validate` | **Inq.** | Adversarial-input binary scan (VulnHunter) | +| `kcode bundle` | **Inq.** | Signed disclosure bundle (GHSA / Bugcrowd / …) | +| `kcode disclose` | **Inq.** | Submit bundle to intake channel | + +Free tier covers small-volume use (5 sessions / month). Paid tiers +(Starter / Pro / Team / Enterprise) unlock concurrency, external +intake submission, and signed bundles without watermark. Pricing, +signup, and self-service tokens: . + +Configuration: + +| Env var | Default | +| -------------------------- | ---------------------------------------------------- | +| `INQUISITOR_URL` | `https://api.astrolexis.space/inquisitor/v1` | +| `INQUISITOR_TOKEN_FILE` | `~/.inquisitor/token` | +| `INQUISITOR_TOKEN` | (overrides the token file if set) | + +Self-hosted Inquisitor (Enterprise tier): point `INQUISITOR_URL` at +your own deployment. KCode is endpoint-agnostic. + +--- + ## Keyboard Shortcuts (TUI) | Key | Action | diff --git a/src/cli/commands/bundle.ts b/src/cli/commands/bundle.ts new file mode 100644 index 0000000..57b72d0 --- /dev/null +++ b/src/cli/commands/bundle.ts @@ -0,0 +1,141 @@ +// KCode — `kcode bundle` command +// +// Generate a signed disclosure bundle from a set of confirmed +// findings + their reproducers + (optionally) validation results. +// The output is a tarball with a signature file, suitable for +// attaching to a GitHub Security Advisory, a Bugcrowd submission, +// or a coordinated-disclosure email thread. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; +import type { Command } from "commander"; +import { + formatInquisitorError, + InquisitorError, + requireInquisitor, + submitToInquisitor, +} from "../../integrations/inquisitor"; + +interface BundleResponse { + session_id: string; + bundle_b64: string; + bundle_filename: string; + signature: string; + format: string; +} + +export function registerBundleCommand(program: Command): void { + program + .command("bundle ") + .description("Generate a signed disclosure bundle from confirmed findings (Inquisitor)") + .option("-r, --report ", "audit report JSON to source findings from", "AUDIT_REPORT.json") + .option("-f, --format ", "output format: ghsa, bugcrowd, soc-email, oss-pr", "ghsa") + .option( + "-o, --out ", + "output file path (default: ./disclosure-bundle.tar.gz)", + "./disclosure-bundle.tar.gz", + ) + .option( + "--include-repro ", + "include a pre-built reproducer directory (from `kcode reproduce`)", + ) + .option("--include-screencast ", "include an asciinema .mp4/.gif demo screencast") + .action( + async ( + findingIds: string[], + opts: { + report: string; + format: string; + out: string; + includeRepro?: string; + includeScreencast?: string; + }, + ) => { + try { + const reportPath = pathResolve(opts.report); + if (!existsSync(reportPath)) { + console.error(`✗ Audit report not found: ${reportPath}`); + process.exit(2); + } + const report = JSON.parse(readFileSync(reportPath, "utf8")) as { + confirmed?: Array>; + }; + const wanted = new Set(findingIds); + const findings = (report.confirmed ?? []).filter((f) => + wanted.has(String(f.id ?? f.finding_id ?? "")), + ); + if (findings.length === 0) { + console.error(`✗ None of the requested findings were in the report`); + process.exit(2); + } + if (findings.length !== findingIds.length) { + const found = new Set(findings.map((f) => String(f.id ?? f.finding_id ?? ""))); + const missing = findingIds.filter((id) => !found.has(id)); + console.error(`! Missing findings (skipped): ${missing.join(", ")}`); + } + + const repro = opts.includeRepro + ? readReproDir(pathResolve(opts.includeRepro)) + : undefined; + const screencast = opts.includeScreencast + ? readBase64(pathResolve(opts.includeScreencast)) + : undefined; + + const pre = await requireInquisitor("bundle"); + console.log( + `→ Inquisitor preflight ok (tier=${pre.tier}, balance=${pre.balance}). Generating ${opts.format} bundle for ${findings.length} finding(s)…`, + ); + + const result = await submitToInquisitor("bundle", { + findings, + format: opts.format, + repro, + screencast, + }); + + const outPath = pathResolve(opts.out); + writeFileSync(outPath, Buffer.from(result.bundle_b64, "base64")); + writeFileSync(`${outPath}.sig`, result.signature); + console.log(`\n✓ Bundle: ${outPath}`); + console.log(` Signature: ${outPath}.sig`); + console.log(` Format: ${result.format}`); + console.log(`\n Verify: inquisitor verify ${outPath}`); + } catch (err) { + if (err instanceof InquisitorError) { + console.error(formatInquisitorError(err)); + process.exit(1); + } + throw err; + } + }, + ); +} + +function readReproDir( + dir: string, +): { manifest: unknown; files: Array<{ path: string; content_b64: string }> } | undefined { + if (!existsSync(dir)) return undefined; + // Minimal: caller passes a directory; Inquisitor will tar+gzip it + // on its side. We just hand over the file list + contents as base64 + // so the bridge stays JSON. + const { readdirSync, statSync } = require("node:fs") as typeof import("node:fs"); + const out: Array<{ path: string; content_b64: string }> = []; + const walk = (root: string, prefix: string) => { + for (const name of readdirSync(root)) { + const p = `${root}/${name}`; + const rel = prefix ? `${prefix}/${name}` : name; + const st = statSync(p); + if (st.isDirectory()) walk(p, rel); + else if (st.isFile()) { + out.push({ path: rel, content_b64: readFileSync(p).toString("base64") }); + } + } + }; + walk(dir, ""); + return { manifest: { source: dir, file_count: out.length }, files: out }; +} + +function readBase64(p: string): string | undefined { + if (!existsSync(p)) return undefined; + return readFileSync(p).toString("base64"); +} diff --git a/src/cli/commands/disclose.ts b/src/cli/commands/disclose.ts new file mode 100644 index 0000000..8781224 --- /dev/null +++ b/src/cli/commands/disclose.ts @@ -0,0 +1,90 @@ +// KCode — `kcode disclose` command +// +// Submit a previously-generated disclosure bundle to an intake +// channel: GitHub Security Advisory (PVR), Bugcrowd VDP, vendor +// SOC email, or an OSS hygiene PR. +// +// This step is the last mile of the audit → disclosure pipeline. +// It is gated behind Inquisitor because (a) the intake adapters +// are non-trivial to maintain, (b) some intakes require signed +// bundles, and (c) audit-trail / receipt persistence lives on +// the Inquisitor side. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; +import type { Command } from "commander"; +import { + formatInquisitorError, + InquisitorError, + requireInquisitor, + submitToInquisitor, +} from "../../integrations/inquisitor"; + +interface DiscloseResponse { + session_id: string; + submission_id: string; + intake: string; + intake_url: string; + state: "submitted" | "queued" | "rejected"; + message?: string; +} + +export function registerDiscloseCommand(program: Command): void { + program + .command("disclose ") + .description("Submit a signed disclosure bundle to an intake channel (Inquisitor)") + .requiredOption( + "-i, --intake ", + "intake spec — examples:\n" + + " ghsa:owner/repo (GitHub Security Advisory, requires PVR enabled)\n" + + " bugcrowd:engagement-slug (Bugcrowd VDP / bounty program)\n" + + " email:security@vendor.com (vendor SOC email with bundle attached)\n" + + " pr:owner/repo (open a public PR with hygiene patches only)", + ) + .option("--dry-run", "validate the bundle + intake spec without actually submitting", false) + .action(async (bundlePath: string, opts: { intake: string; dryRun: boolean }) => { + try { + const p = pathResolve(bundlePath); + if (!existsSync(p)) { + console.error(`✗ Bundle not found: ${p}`); + console.error(" Generate one first with: kcode bundle "); + process.exit(2); + } + const sigPath = `${p}.sig`; + if (!existsSync(sigPath)) { + console.error(`✗ Bundle signature not found: ${sigPath}`); + console.error(" Re-run `kcode bundle` to produce a signed bundle."); + process.exit(2); + } + const bundle_b64 = readFileSync(p).toString("base64"); + const signature = readFileSync(sigPath, "utf8").trim(); + + const pre = await requireInquisitor("disclose"); + console.log(`→ Inquisitor preflight ok (tier=${pre.tier}, balance=${pre.balance}).`); + console.log(` ${opts.dryRun ? "Dry-run validation" : "Submitting"} to ${opts.intake}…`); + + const result = await submitToInquisitor("disclose", { + bundle_b64, + signature, + intake: opts.intake, + dry_run: opts.dryRun, + }); + + if (opts.dryRun) { + console.log(`\n✓ Dry-run ok — intake '${result.intake}' would accept the bundle.`); + return; + } + console.log(`\n✓ Submitted: ${result.submission_id}`); + console.log(` State: ${result.state}`); + console.log(` Intake: ${result.intake}`); + console.log(` URL: ${result.intake_url}`); + if (result.message) console.log(` Message: ${result.message}`); + } catch (err) { + if (err instanceof InquisitorError) { + console.error(formatInquisitorError(err)); + process.exit(1); + } + throw err; + } + }); +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 8794525..70603a6 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -2,10 +2,12 @@ export { registerAneCommand } from "./ane"; export { registerAuditCommand } from "./audit"; export { registerAuthCommand } from "./auth"; export { registerBenchmarkCommands } from "./benchmark"; +export { registerBundleCommand } from "./bundle"; export { registerCloudCommand } from "./cloud"; export { registerCompletionsCommand } from "./completions"; export { registerDaemonCommand } from "./daemon"; export { registerDashboardCommand } from "./dashboard"; +export { registerDiscloseCommand } from "./disclose"; export { registerDistillCommand } from "./distill"; export { registerDoctorCommand } from "./doctor"; export { registerGrammarsCommand } from "./grammars"; @@ -21,6 +23,7 @@ export { registerPluginCommand } from "./plugin"; export { registerProCommands } from "./pro"; export { registerRagCommand } from "./rag"; export { registerRemoteCommand } from "./remote"; +export { registerReproduceCommand } from "./reproduce"; export { registerResumeCommand } from "./resume"; export { registerSbomCommand } from "./sbom"; export { registerSearchCommand } from "./search"; @@ -33,5 +36,6 @@ export { registerTeachCommand } from "./teach"; export { registerTemplateCommand } from "./template"; export { registerTriggersCommand } from "./triggers"; export { registerUpdateCommand } from "./update"; +export { registerValidateCommand } from "./validate"; export { registerWatchCommand } from "./watch"; export { registerWebCommand } from "./web"; diff --git a/src/cli/commands/reproduce.ts b/src/cli/commands/reproduce.ts new file mode 100644 index 0000000..eb32aea --- /dev/null +++ b/src/cli/commands/reproduce.ts @@ -0,0 +1,97 @@ +// KCode — `kcode reproduce` command +// +// Build a standalone, compilable reproducer for a confirmed finding +// surfaced by `kcode audit`. The reproducer is a single-file (or +// minimal multi-file) artifact that demonstrates the bug shape +// outside the upstream repo — the same format that NASA F´ accepted +// for advisory GHSA-x8cp-v4fr-fg2x. +// +// Reproducer synthesis is done by Mender (Inquisitor sidecar); +// KCode acts as the client of the bridge. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join as pathJoin, resolve as pathResolve } from "node:path"; +import type { Command } from "commander"; +import { + formatInquisitorError, + InquisitorError, + requireInquisitor, + submitToInquisitor, +} from "../../integrations/inquisitor"; + +interface ReproduceResponse { + session_id: string; + artifacts: Array<{ path: string; content: string; executable?: boolean }>; + notes?: string; +} + +export function registerReproduceCommand(program: Command): void { + program + .command("reproduce ") + .description("Build a standalone compilable reproducer for a confirmed finding (Inquisitor)") + .option( + "-r, --report ", + "audit report JSON to look up the finding in", + "AUDIT_REPORT.json", + ) + .option("-o, --out ", "output directory for the reproducer artifacts", "./repro") + .option( + "-l, --language ", + "preferred reproducer language (c, cpp, java, python, js); default = auto", + ) + .action(async (findingId: string, opts: { report: string; out: string; language?: string }) => { + try { + const reportPath = pathResolve(opts.report); + if (!existsSync(reportPath)) { + console.error(`✗ Audit report not found: ${reportPath}`); + console.error(" Run `kcode audit ` first, or pass --report explicitly."); + process.exit(2); + } + const report = JSON.parse(readFileSync(reportPath, "utf8")) as { + confirmed?: Array>; + }; + const finding = (report.confirmed ?? []).find((f) => { + const id = String(f.id ?? f.finding_id ?? ""); + return id === findingId; + }); + if (!finding) { + console.error(`✗ Finding '${findingId}' not found in ${reportPath}`); + console.error(" Available IDs:"); + for (const f of (report.confirmed ?? []).slice(0, 20)) { + console.error(` ${f.id ?? f.finding_id} — ${f.pattern ?? "(no pattern)"}`); + } + process.exit(2); + } + + const pre = await requireInquisitor("reproduce"); + console.log( + `→ Inquisitor preflight ok (tier=${pre.tier}, balance=${pre.balance}). Building reproducer…`, + ); + + const result = await submitToInquisitor("reproduce", { + finding, + target_language: opts.language, + }); + + const outDir = pathResolve(opts.out); + mkdirSync(outDir, { recursive: true }); + for (const a of result.artifacts) { + const dest = pathJoin(outDir, a.path); + mkdirSync(pathResolve(dest, ".."), { recursive: true }); + writeFileSync(dest, a.content, a.executable ? { mode: 0o755 } : undefined); + console.log(` wrote ${dest}`); + } + if (result.notes) { + console.log(`\nNotes from Inquisitor:\n${result.notes}`); + } + console.log(`\n✓ Reproducer ready: ${outDir}`); + console.log(` Try: cd ${outDir} && make demo`); + } catch (err) { + if (err instanceof InquisitorError) { + console.error(formatInquisitorError(err)); + process.exit(1); + } + throw err; + } + }); +} diff --git a/src/cli/commands/validate.ts b/src/cli/commands/validate.ts new file mode 100644 index 0000000..636fcaa --- /dev/null +++ b/src/cli/commands/validate.ts @@ -0,0 +1,87 @@ +// KCode — `kcode validate` command +// +// Run a binary scan against a compiled target to confirm that a +// static finding actually crashes the binary under adversarial +// input. Delegates to Inquisitor's VulnHunter agent. + +import { existsSync, statSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; +import type { Command } from "commander"; +import { + formatInquisitorError, + InquisitorError, + requireInquisitor, + submitToInquisitor, +} from "../../integrations/inquisitor"; + +interface ValidateResponse { + session_id: string; + job_id: string; + status: "running" | "done" | "failed"; + crashes: Array<{ + input_b64: string; + signal?: string; + exit_code?: number; + short_trace?: string; + }>; + runs_completed: number; + duration_seconds: number; +} + +export function registerValidateCommand(program: Command): void { + program + .command("validate ") + .description( + "Run adversarial-input binary scan against a target to validate findings (Inquisitor)", + ) + .option("-f, --finding ", "validate a specific finding from the audit report") + .option("-n, --probes ", "number of scenarios to generate", "10") + .option("-r, --reruns ", "reruns per scenario to filter false positives", "4") + .action(async (binary: string, opts: { finding?: string; probes: string; reruns: string }) => { + try { + const binPath = pathResolve(binary); + if (!existsSync(binPath)) { + console.error(`✗ Binary not found: ${binPath}`); + process.exit(2); + } + const st = statSync(binPath); + if (!st.isFile()) { + console.error(`✗ Not a regular file: ${binPath}`); + process.exit(2); + } + + const pre = await requireInquisitor("validate"); + console.log( + `→ Inquisitor preflight ok (tier=${pre.tier}, balance=${pre.balance}). Submitting scan…`, + ); + + const result = await submitToInquisitor("validate", { + binary_path: binPath, + finding_id: opts.finding, + probes: Number(opts.probes), + reruns: Number(opts.reruns), + }); + + console.log(`\n✓ Scan job ${result.job_id} ${result.status}`); + console.log(` Runs: ${result.runs_completed} · Duration: ${result.duration_seconds}s`); + if (result.crashes.length === 0) { + console.log(` Crashes: 0 — VulnHunter could not validate the finding`); + } else { + console.log(` Crashes: ${result.crashes.length}`); + for (const [i, c] of result.crashes.entries()) { + console.log( + ` [${i + 1}] signal=${c.signal ?? "?"} exit=${c.exit_code ?? "?"}` + + (c.short_trace ? `\n ${c.short_trace.split("\n")[0]}` : ""), + ); + } + console.log(`\n Pull full crash details: inquisitor inspect ${result.job_id} --full`); + } + } catch (err) { + if (err instanceof InquisitorError) { + console.error(formatInquisitorError(err)); + process.exit(1); + } + throw err; + } + }); +} diff --git a/src/index.ts b/src/index.ts index ec05dcc..718bd35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,10 +77,12 @@ import { registerAuditCommand, registerAuthCommand, registerBenchmarkCommands, + registerBundleCommand, registerCloudCommand, registerCompletionsCommand, registerDaemonCommand, registerDashboardCommand, + registerDiscloseCommand, registerDistillCommand, registerDoctorCommand, registerGrammarsCommand, @@ -96,6 +98,7 @@ import { registerProCommands, registerRagCommand, registerRemoteCommand, + registerReproduceCommand, registerResumeCommand, registerSbomCommand, registerSearchCommand, @@ -108,6 +111,7 @@ import { registerTemplateCommand, registerTriggersCommand, registerUpdateCommand, + registerValidateCommand, registerWatchCommand, registerWebCommand, } from "./cli/commands"; @@ -509,6 +513,14 @@ registerDashboardCommand(program); registerTemplateCommand(program); registerWebCommand(program); +// Inquisitor bridge — CVE-grade evidence (reproducer, validation, +// signed bundle, intake submission). KCode handles discovery + fix; +// these final-mile steps live behind the Inquisitor service. +registerReproduceCommand(program); +registerValidateCommand(program); +registerBundleCommand(program); +registerDiscloseCommand(program); + // Note: model auto-discovery now lives in src/ui/App.tsx (fires at // TUI mount with a throttle) instead of here, so non-TUI CLI // subcommands (kcode build, kcode status, etc.) don't make API diff --git a/src/integrations/inquisitor.ts b/src/integrations/inquisitor.ts new file mode 100644 index 0000000..ac6f49f --- /dev/null +++ b/src/integrations/inquisitor.ts @@ -0,0 +1,207 @@ +// KCode — Inquisitor Bridge Client +// +// KCode handles discovery, verification, and agentic fix generation +// of confirmed findings — all free, all open source. +// +// CVE-grade evidence (compilable standalone reproducers, binary +// validation, signed disclosure bundles, intake submission) is +// produced by Inquisitor, a paid sister daemon. KCode talks to +// Inquisitor over HTTP through this thin bridge module. +// +// Why a bridge: KCode is Apache 2.0 and shipped to anyone; the +// proprietary work (Mender shim synthesis, VulnHunter binary scan, +// Pathfinder coordination + bundle signing) stays behind the +// service boundary. This module owns the contract. +// +// Default endpoint: https://api.astrolexis.space/inquisitor/v1 +// Override: INQUISITOR_URL env var (for self-hosted, dev, +// or staging) +// Token storage: ~/.inquisitor/token (single-line bearer token) +// Token issuance: https://astrolexis.space/inquisitor + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join as pathJoin } from "node:path"; + +const DEFAULT_BRIDGE_URL = "https://api.astrolexis.space/inquisitor/v1"; +const DEFAULT_TOKEN_PATH = pathJoin(homedir(), ".inquisitor", "token"); + +export type InquisitorAction = "reproduce" | "validate" | "bundle" | "disclose"; + +export interface PreflightResult { + session_id: string; + balance: number; + tier: "free" | "starter" | "pro" | "team" | "enterprise"; +} + +export class InquisitorError extends Error { + readonly hint?: string; + readonly httpStatus?: number; + constructor(message: string, opts?: { hint?: string; httpStatus?: number }) { + super(message); + this.name = "InquisitorError"; + this.hint = opts?.hint; + this.httpStatus = opts?.httpStatus; + } +} + +function bridgeUrl(): string { + return process.env.INQUISITOR_URL ?? DEFAULT_BRIDGE_URL; +} + +function tokenPath(): string { + return process.env.INQUISITOR_TOKEN_FILE ?? DEFAULT_TOKEN_PATH; +} + +/** + * Read the bearer token. Throws InquisitorError with a clear hint + * if the token file is missing. + */ +export function readToken(): string { + const envToken = process.env.INQUISITOR_TOKEN; + if (envToken && envToken.trim().length > 0) { + return envToken.trim(); + } + const p = tokenPath(); + if (!existsSync(p)) { + throw new InquisitorError("Inquisitor token not found.", { + hint: + "These commands require an Inquisitor account (KCode's paid sister service " + + "for CVE-grade evidence: standalone reproducers, binary validation, " + + "signed disclosure bundles).\n\n" + + " • Sign up: https://astrolexis.space/inquisitor\n" + + " • Already have a token? Save it with:\n" + + " mkdir -p ~/.inquisitor && echo '' > ~/.inquisitor/token\n" + + " or export INQUISITOR_TOKEN= in your shell.", + }); + } + const raw = readFileSync(p, "utf8").trim(); + if (raw.length === 0) { + throw new InquisitorError("Inquisitor token file is empty.", { + hint: `Edit ${p} and put your token on a single line, or unset and use INQUISITOR_TOKEN env var.`, + }); + } + return raw; +} + +/** + * Verify Inquisitor reachability + token validity + session balance + * before running a gated command. Returns the preflight metadata + * (session id, balance, tier). + * + * Throws InquisitorError with a hint pointing at the resolution + * path (signup, login, top-up, etc.). + */ +export async function requireInquisitor(action: InquisitorAction): Promise { + const token = readToken(); + let res: Response; + try { + res = await fetch(`${bridgeUrl()}/preflight`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": "kcode/inquisitor-bridge", + }, + body: JSON.stringify({ action }), + }); + } catch (err) { + throw new InquisitorError("Could not reach the Inquisitor bridge.", { + hint: + `Tried: ${bridgeUrl()}/preflight\n\n` + + "Possible causes:\n" + + " • Network / DNS issue from this machine\n" + + " • Astrolexis is currently down (status: https://astrolexis.space/status)\n" + + " • You are pointing INQUISITOR_URL at a host that isn't running\n\n" + + `Underlying error: ${(err as Error).message}`, + }); + } + + if (res.status === 401) { + throw new InquisitorError("Inquisitor token rejected (401).", { + httpStatus: 401, + hint: + "Your token is invalid or expired.\n" + + " • Reissue: https://astrolexis.space/inquisitor/account/token", + }); + } + if (res.status === 402) { + throw new InquisitorError("No Inquisitor sessions remaining.", { + httpStatus: 402, + hint: + "Your tier's monthly session quota is exhausted.\n" + + " • Top up or upgrade: https://astrolexis.space/inquisitor/billing", + }); + } + if (res.status === 403) { + throw new InquisitorError(`Inquisitor refused the '${action}' action for this tier.`, { + httpStatus: 403, + hint: + "The Free tier disables some actions (e.g. `disclose` to external intakes).\n" + + " • Upgrade: https://astrolexis.space/inquisitor", + }); + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new InquisitorError(`Inquisitor preflight failed (HTTP ${res.status}).`, { + httpStatus: res.status, + hint: text ? `Server said: ${text.slice(0, 400)}` : undefined, + }); + } + + return (await res.json()) as PreflightResult; +} + +/** + * Submit a payload to a gated Inquisitor endpoint. The action + * argument is one of the canonical command names; the payload + * shape is action-specific (the caller knows it). Returns the + * parsed JSON body on success. + * + * Callers should run requireInquisitor() first; this function + * does NOT re-do the preflight, it just posts the work. + */ +export async function submitToInquisitor( + action: InquisitorAction, + payload: object, +): Promise { + const token = readToken(); + const url = `${bridgeUrl()}/${action}`; + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": "kcode/inquisitor-bridge", + }, + body: JSON.stringify(payload), + }); + } catch (err) { + throw new InquisitorError(`Could not reach Inquisitor for action '${action}'.`, { + hint: `Tried: ${url}\nUnderlying: ${(err as Error).message}`, + }); + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new InquisitorError(`Inquisitor '${action}' failed (HTTP ${res.status}).`, { + httpStatus: res.status, + hint: text ? `Server said: ${text.slice(0, 800)}` : undefined, + }); + } + return (await res.json()) as T; +} + +/** + * Format an InquisitorError for terminal output. Returns a string + * with the headline message + hint, suitable for `console.error`. + */ +export function formatInquisitorError(err: InquisitorError): string { + const lines = [`\x1b[31m✗\x1b[0m ${err.message}`]; + if (err.hint) { + lines.push(""); + for (const ln of err.hint.split("\n")) lines.push(` ${ln}`); + } + return lines.join("\n"); +}