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

security-sidecar-client.ts

234 lines · browse/src/security-sidecar-client.ts at 3f79d81

1/**
2 * Security sidecar client — IPC layer for the Node L4 classifier subprocess.
3 *
4 * Spawn model: lazy. First call to scan() spawns the sidecar, warms it (the
5 * sidecar's loadTestsavant call on first scan-page-content), and reuses
6 * the same process for every subsequent scan. The process dies when the
7 * browse server exits (Node's stdin-close behavior).
8 *
9 * Reliability:
10 * - 5s default timeout per scan. Caller can override per-call.
11 * - 64KB request cap. Larger payloads short-circuit with `payload-too-large`.
12 * - Respawn capped at 3 failures within 10 minutes; further failures
13 * trip a circuit breaker that returns `available: false` until reset.
14 * - Parent-exit cleanup: process.on('exit') sends SIGTERM to the child.
15 *
16 * Failure semantics:
17 * - Node not on PATH → available() returns false; caller (the
18 * /pty-inject-scan endpoint) returns l4: { available: false } and the
19 * extension degrades to WARN + user confirm.
20 * - Scan throws or times out → caller treats as L4-unavailable for that
21 * request and falls through to L1-L3-only verdict.
22 *
23 * Single-process singleton. Multiple callers within the same browse
24 * process share one sidecar.
25 */
27import { ChildProcessByStdio, spawn } from "child_process";
28import { Readable, Writable } from "stream";
29import { findSecuritySidecar } from "./find-security-sidecar";
31const REQUEST_CAP_BYTES = 64 * 1024;
32const DEFAULT_TIMEOUT_MS = 5000;
33const RESPAWN_WINDOW_MS = 10 * 60 * 1000;
34const RESPAWN_LIMIT = 3;
36interface PendingRequest {
37 resolve: (response: unknown) => void;
38 reject: (err: Error) => void;
39 timer: ReturnType<typeof setTimeout>;
42interface SidecarState {
43 child: ChildProcessByStdio<Writable, Readable, Readable> | null;
44 pending: Map<string, PendingRequest>;
45 buffer: string;
46 failures: number[]; // timestamps of recent failures
47 available: boolean;
48 /** True after circuit-breaker tripped; stays true until reset() */
49 brokenCircuit: boolean;
50 nextId: number;
53let state: SidecarState | null = null;
55function getState(): SidecarState {
56 if (!state) {
57 state = {
58 child: null,
59 pending: new Map(),
60 buffer: "",
61 failures: [],
62 available: true,
63 brokenCircuit: false,
64 nextId: 1,
65 };
66 }
67 return state;
70function recordFailure(): void {
71 const s = getState();
72 const now = Date.now();
73 s.failures = s.failures.filter((t) => now - t < RESPAWN_WINDOW_MS);
74 s.failures.push(now);
75 if (s.failures.length >= RESPAWN_LIMIT) {
76 s.brokenCircuit = true;
77 s.available = false;
78 }
81function processBuffer(): void {
82 const s = getState();
83 let idx = s.buffer.indexOf("\n");
84 while (idx !== -1) {
85 const line = s.buffer.slice(0, idx).trim();
86 s.buffer = s.buffer.slice(idx + 1);
87 idx = s.buffer.indexOf("\n");
88 if (!line) continue;
89 let parsed: { id?: string; ok?: boolean; verdict?: unknown; status?: unknown; error?: string };
90 try {
91 parsed = JSON.parse(line);
92 } catch {
93 // Malformed line — record as failure but don't reject any specific
94 // pending request (we don't know which one this was meant for).
95 recordFailure();
96 continue;
97 }
98 const id = typeof parsed.id === "string" ? parsed.id : null;
99 if (!id) continue;
100 const pending = s.pending.get(id);
101 if (!pending) continue;
102 s.pending.delete(id);
103 clearTimeout(pending.timer);
104 if (parsed.ok) {
105 pending.resolve(parsed);
106 } else {
107 recordFailure();
108 pending.reject(new Error(parsed.error ?? "sidecar-error"));
109 }
110 }
113function shutdownChild(): void {
114 const s = getState();
115 if (!s.child) return;
116 try {
117 s.child.kill("SIGTERM");
118 } catch {
119 // Already dead.
120 }
121 s.child = null;
122 for (const [, p] of s.pending) {
123 clearTimeout(p.timer);
124 p.reject(new Error("sidecar-died"));
125 }
126 s.pending.clear();
129function spawnSidecar(): boolean {
130 const s = getState();
131 if (s.brokenCircuit) return false;
132 const location = findSecuritySidecar();
133 if (!location) {
134 s.available = false;
135 return false;
136 }
137 try {
138 const child = spawn(location.node, [location.entry], {
139 stdio: ["pipe", "pipe", "pipe"],
140 detached: false,
141 // Long-lived Node sidecar — without this, Windows gives it a console
142 // window that sits on the taskbar for the daemon's whole lifetime.
143 windowsHide: true,
144 });
145 child.stdout.on("data", (chunk: Buffer) => {
146 s.buffer += chunk.toString("utf-8");
147 processBuffer();
148 });
149 child.on("exit", () => {
150 shutdownChild();
151 });
152 child.on("error", () => {
153 recordFailure();
154 shutdownChild();
155 });
156 s.child = child;
157 s.available = true;
158 return true;
159 } catch {
160 recordFailure();
161 return false;
162 }
165// Best-effort parent-exit cleanup. Node's "exit" event blocks async work, so
166// we send SIGTERM synchronously and let the OS reap the child.
167process.on("exit", () => shutdownChild());
169export interface SidecarAvailability {
170 available: boolean;
171 reason?: string;
174export function isSidecarAvailable(): SidecarAvailability {
175 const s = getState();
176 if (s.brokenCircuit) return { available: false, reason: "circuit-broken" };
177 if (s.child) return { available: true };
178 // Probe via findSecuritySidecar without spawning. If the resolver returns
179 // null (no node on PATH, no entry on disk), we're permanently unavailable
180 // until a setup re-run.
181 const location = findSecuritySidecar();
182 if (!location) return { available: false, reason: "no-node-or-entry" };
183 return { available: true };
186export async function scanWithSidecar(text: string, opts?: { timeoutMs?: number }): Promise<{ verdict: unknown }> {
187 const s = getState();
188 if (s.brokenCircuit) {
189 throw new Error("sidecar-circuit-broken");
190 }
191 if (Buffer.byteLength(text, "utf-8") > REQUEST_CAP_BYTES) {
192 throw new Error("payload-too-large");
193 }
194 if (!s.child) {
195 if (!spawnSidecar()) {
196 throw new Error("sidecar-spawn-failed");
197 }
198 }
199 const id = String(s.nextId++);
200 const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
202 return new Promise((resolve, reject) => {
203 const timer = setTimeout(() => {
204 s.pending.delete(id);
205 recordFailure();
206 reject(new Error("sidecar-timeout"));
207 }, timeoutMs);
209 s.pending.set(id, {
210 resolve: (response: unknown) => {
211 const r = response as { verdict?: unknown };
212 resolve({ verdict: r.verdict });
213 },
214 reject,
215 timer,
216 });
218 const payload = JSON.stringify({ id, op: "scan-page-content", text }) + "\n";
219 try {
220 s.child!.stdin.write(payload);
221 } catch (err) {
222 clearTimeout(timer);
223 s.pending.delete(id);
224 recordFailure();
225 reject(err instanceof Error ? err : new Error(String(err)));
226 }
227 });
230/** Reset the circuit breaker. Test-only escape hatch. */
231export function resetSidecarForTests(): void {
232 shutdownChild();
233 state = null;