Skill Tracer
Skills › gstack › browse › security-sidecar-entry.ts

security-sidecar-entry.ts

120 lines · browse/src/security-sidecar-entry.ts at 3f79d81

1/**
2 * Security sidecar entry — Node script that hosts the L4 ML classifier on
3 * behalf of the compiled browse server.
4 *
5 * Why a sidecar:
6 * - browse/src/security-classifier.ts depends on @huggingface/transformers
7 * which loads onnxruntime-node, a native module that fails to `dlopen`
8 * from Bun's compile-binary temp extraction dir (CLAUDE.md "Sidebar
9 * security stack" section). Importing the classifier into server.ts
10 * would brick the compiled binary at startup.
11 * - sidebar-agent.ts (the previous host of the classifier) was removed
12 * when the PTY proved out. The classifier file still ships but had no
13 * caller — exactly the gap codex flagged in #1370.
14 *
15 * This entry runs under plain Node (resolved by find-security-sidecar.ts).
16 * It reads NDJSON requests from stdin and writes NDJSON responses to stdout.
17 *
18 * Protocol (one JSON object per line, both directions):
19 * request: { id: string, op: "scan-page-content" | "ping", text?: string }
20 * response: { id: string, ok: true, verdict: LayerSignal } |
21 * { id: string, ok: false, error: string }
22 *
23 * Lifecycle:
24 * - Spawned lazily by security-sidecar-client.ts on first /pty-inject-scan
25 * - Exits when stdin closes (parent gone) — standard Node behavior
26 * - Exits on SIGTERM cleanly
27 *
28 * Failure modes:
29 * - Model download fails → reply { ok: false, error: "model-load" } and
30 * keep the loop alive for the next request (caller decides whether to
31 * retry or fail-safe to L1-L3-only)
32 */
34import * as readline from "readline";
35import { scanPageContent, getClassifierStatus, loadTestsavant } from "./security-classifier";
37interface Request {
38 id: string;
39 op: "scan-page-content" | "ping" | "status";
40 text?: string;
43interface OkResponse {
44 id: string;
45 ok: true;
46 verdict?: unknown;
47 status?: unknown;
50interface ErrResponse {
51 id: string;
52 ok: false;
53 error: string;
56function write(obj: OkResponse | ErrResponse): void {
57 process.stdout.write(JSON.stringify(obj) + "\n");
60async function handle(req: Request): Promise<void> {
61 if (!req || typeof req.id !== "string") {
62 // Drop unidentifiable requests silently — protocol invariant.
63 return;
64 }
65 try {
66 if (req.op === "ping") {
67 write({ id: req.id, ok: true, verdict: { layer: "ping", verdict: "alive", score: 0 } });
68 return;
69 }
70 if (req.op === "status") {
71 write({ id: req.id, ok: true, status: getClassifierStatus() });
72 return;
73 }
74 if (req.op === "scan-page-content") {
75 if (typeof req.text !== "string") {
76 write({ id: req.id, ok: false, error: "missing-text" });
77 return;
78 }
79 // Warm the classifier once per process; subsequent scans are fast.
80 await loadTestsavant().catch(() => {
81 // loadTestsavant degrades gracefully; scanPageContent below will
82 // return a fail-open verdict if the model never loaded.
83 });
84 const verdict = await scanPageContent(req.text);
85 write({ id: req.id, ok: true, verdict });
86 return;
87 }
88 write({ id: req.id, ok: false, error: `unknown-op:${(req as { op?: unknown }).op}` });
89 } catch (err) {
90 const msg = err instanceof Error ? err.message : String(err);
91 write({ id: req.id, ok: false, error: msg });
92 }
95function main(): void {
96 // readline buffers stdin into one-line chunks. Stay alive until stdin
97 // closes (parent gone) — Node exits naturally then.
98 const rl = readline.createInterface({ input: process.stdin });
99 rl.on("line", (line) => {
100 if (!line.trim()) return;
101 let req: Request;
102 try {
103 req = JSON.parse(line) as Request;
104 } catch {
105 // Malformed line — write a generic error without an id, callers can
106 // detect via missing id and trip the circuit breaker.
107 write({ id: "<malformed>", ok: false, error: "malformed-json" });
108 return;
109 }
110 // Fire-and-forget; concurrent requests get id-correlated responses.
111 void handle(req);
112 });
113 rl.on("close", () => {
114 process.exit(0);
115 });
116 process.on("SIGTERM", () => process.exit(0));
117 process.on("SIGINT", () => process.exit(0));
120main();