diff --git a/browse/src/find-security-sidecar.ts b/browse/src/find-security-sidecar.ts new file mode 100644 index 000000000..0ba242523 --- /dev/null +++ b/browse/src/find-security-sidecar.ts @@ -0,0 +1,78 @@ +/** + * find-security-sidecar — resolve the Node entry that runs the L4 ML + * classifier sidecar. + * + * The sidecar can't be bundled into the compiled browse binary because + * onnxruntime-node fails to dlopen from Bun's compile extract dir. It runs + * as a separate Node subprocess instead. This module resolves the right + * path + interpreter on each platform: + * + * 1. Prefer node on PATH + a bundled JS entry at + * browse/dist/security-sidecar.js (built by package.json's + * build:security-sidecar script). + * 2. Dev fallback: node + browse/src/security-sidecar-entry.ts via tsx + * (only available in the source checkout, not the compiled install). + * 3. If Node is missing or no entry resolves, return null. The /pty-inject-scan + * endpoint then responds with l4 { available: false } and the extension + * degrades to WARN+confirm (D7). + */ + +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { execFileSync } from "child_process"; + +export interface SidecarLocation { + node: string; + entry: string; + /** "compiled" if running from browse/dist/, "dev" if running from src */ + mode: "compiled" | "dev"; +} + +function nodeOnPath(): string | null { + try { + execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000 }); + return "node"; + } catch { + return null; + } +} + +function browseRoot(): string { + // When running compiled, __dirname (via import.meta.dir) points at the + // Bun extract temp. Walk up until we find a directory containing + // browse/dist/ or browse/src/. + let candidate = dirname(import.meta.path || ""); + for (let i = 0; i < 6; i += 1) { + if (existsSync(join(candidate, "browse", "dist", "security-sidecar.js"))) { + return candidate; + } + if (existsSync(join(candidate, "src", "security-sidecar-entry.ts"))) { + return candidate; + } + const next = dirname(candidate); + if (next === candidate) break; + candidate = next; + } + return process.cwd(); +} + +export function findSecuritySidecar(): SidecarLocation | null { + const node = nodeOnPath(); + if (!node) return null; + + const root = browseRoot(); + + const compiled = join(root, "browse", "dist", "security-sidecar.js"); + if (existsSync(compiled)) { + return { node, entry: compiled, mode: "compiled" }; + } + + // Dev fallback. Compiled installs won't have src/ on disk so this only + // resolves when running from the source checkout. + const devEntry = join(root, "src", "security-sidecar-entry.ts"); + if (existsSync(devEntry)) { + return { node, entry: devEntry, mode: "dev" }; + } + + return null; +} diff --git a/browse/src/security-sidecar-entry.ts b/browse/src/security-sidecar-entry.ts new file mode 100644 index 000000000..bd10285ee --- /dev/null +++ b/browse/src/security-sidecar-entry.ts @@ -0,0 +1,120 @@ +/** + * Security sidecar entry — Node script that hosts the L4 ML classifier on + * behalf of the compiled browse server. + * + * Why a sidecar: + * - browse/src/security-classifier.ts depends on @huggingface/transformers + * which loads onnxruntime-node, a native module that fails to `dlopen` + * from Bun's compile-binary temp extraction dir (CLAUDE.md "Sidebar + * security stack" section). Importing the classifier into server.ts + * would brick the compiled binary at startup. + * - sidebar-agent.ts (the previous host of the classifier) was removed + * when the PTY proved out. The classifier file still ships but had no + * caller — exactly the gap codex flagged in #1370. + * + * This entry runs under plain Node (resolved by find-security-sidecar.ts). + * It reads NDJSON requests from stdin and writes NDJSON responses to stdout. + * + * Protocol (one JSON object per line, both directions): + * request: { id: string, op: "scan-page-content" | "ping", text?: string } + * response: { id: string, ok: true, verdict: LayerSignal } | + * { id: string, ok: false, error: string } + * + * Lifecycle: + * - Spawned lazily by security-sidecar-client.ts on first /pty-inject-scan + * - Exits when stdin closes (parent gone) — standard Node behavior + * - Exits on SIGTERM cleanly + * + * Failure modes: + * - Model download fails → reply { ok: false, error: "model-load" } and + * keep the loop alive for the next request (caller decides whether to + * retry or fail-safe to L1-L3-only) + */ + +import * as readline from "readline"; +import { scanPageContent, getClassifierStatus, loadTestsavant } from "./security-classifier"; + +interface Request { + id: string; + op: "scan-page-content" | "ping" | "status"; + text?: string; +} + +interface OkResponse { + id: string; + ok: true; + verdict?: unknown; + status?: unknown; +} + +interface ErrResponse { + id: string; + ok: false; + error: string; +} + +function write(obj: OkResponse | ErrResponse): void { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +async function handle(req: Request): Promise { + if (!req || typeof req.id !== "string") { + // Drop unidentifiable requests silently — protocol invariant. + return; + } + try { + if (req.op === "ping") { + write({ id: req.id, ok: true, verdict: { layer: "ping", verdict: "alive", score: 0 } }); + return; + } + if (req.op === "status") { + write({ id: req.id, ok: true, status: getClassifierStatus() }); + return; + } + if (req.op === "scan-page-content") { + if (typeof req.text !== "string") { + write({ id: req.id, ok: false, error: "missing-text" }); + return; + } + // Warm the classifier once per process; subsequent scans are fast. + await loadTestsavant().catch(() => { + // loadTestsavant degrades gracefully; scanPageContent below will + // return a fail-open verdict if the model never loaded. + }); + const verdict = await scanPageContent(req.text); + write({ id: req.id, ok: true, verdict }); + return; + } + write({ id: req.id, ok: false, error: `unknown-op:${(req as { op?: unknown }).op}` }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + write({ id: req.id, ok: false, error: msg }); + } +} + +function main(): void { + // readline buffers stdin into one-line chunks. Stay alive until stdin + // closes (parent gone) — Node exits naturally then. + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + if (!line.trim()) return; + let req: Request; + try { + req = JSON.parse(line) as Request; + } catch { + // Malformed line — write a generic error without an id, callers can + // detect via missing id and trip the circuit breaker. + write({ id: "", ok: false, error: "malformed-json" }); + return; + } + // Fire-and-forget; concurrent requests get id-correlated responses. + void handle(req); + }); + rl.on("close", () => { + process.exit(0); + }); + process.on("SIGTERM", () => process.exit(0)); + process.on("SIGINT", () => process.exit(0)); +} + +main();