@massu/core 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/commands/README.md +23 -11
  2. package/commands/massu-deploy.python-docker.md +170 -0
  3. package/commands/massu-deploy.python-fly.md +189 -0
  4. package/commands/massu-deploy.python-launchd.md +144 -0
  5. package/commands/massu-deploy.python-systemd.md +163 -0
  6. package/commands/massu-scaffold-page.swift.md +10 -10
  7. package/commands/massu-scaffold-router.python-django.md +153 -0
  8. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  9. package/dist/cli.js +9914 -4133
  10. package/dist/hooks/auto-learning-pipeline.js +45 -2
  11. package/dist/hooks/classify-failure.js +45 -2
  12. package/dist/hooks/cost-tracker.js +45 -2
  13. package/dist/hooks/fix-detector.js +45 -2
  14. package/dist/hooks/incident-pipeline.js +45 -2
  15. package/dist/hooks/post-edit-context.js +45 -2
  16. package/dist/hooks/post-tool-use.js +45 -2
  17. package/dist/hooks/pre-compact.js +45 -2
  18. package/dist/hooks/pre-delete-check.js +45 -2
  19. package/dist/hooks/quality-event.js +45 -2
  20. package/dist/hooks/rule-enforcement-pipeline.js +45 -2
  21. package/dist/hooks/session-end.js +45 -2
  22. package/dist/hooks/session-start.js +4790 -406
  23. package/dist/hooks/user-prompt.js +45 -2
  24. package/package.json +13 -4
  25. package/src/cli.ts +22 -2
  26. package/src/commands/config-refresh.ts +91 -23
  27. package/src/commands/init.ts +131 -24
  28. package/src/commands/install-commands.ts +142 -26
  29. package/src/commands/refresh-log.ts +37 -0
  30. package/src/commands/template-engine.ts +260 -0
  31. package/src/commands/watch.ts +430 -0
  32. package/src/config.ts +71 -0
  33. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  34. package/src/detect/adapters/parse-guard.ts +133 -0
  35. package/src/detect/adapters/python-django.ts +208 -0
  36. package/src/detect/adapters/python-fastapi.ts +223 -0
  37. package/src/detect/adapters/query-helpers.ts +170 -0
  38. package/src/detect/adapters/runner.ts +252 -0
  39. package/src/detect/adapters/swift-swiftui.ts +171 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +467 -0
  41. package/src/detect/adapters/types.ts +173 -0
  42. package/src/detect/codebase-introspector.ts +190 -0
  43. package/src/detect/index.ts +28 -2
  44. package/src/detect/migrate.ts +4 -4
  45. package/src/detect/regex-fallback.ts +449 -0
  46. package/src/hooks/session-start.ts +94 -3
  47. package/src/lib/gitToplevel.ts +22 -0
  48. package/src/lib/installLock.ts +179 -0
  49. package/src/lib/pidLiveness.ts +67 -0
  50. package/src/lsp/auto-detect.ts +98 -0
  51. package/src/lsp/client.ts +776 -0
  52. package/src/lsp/enrich.ts +127 -0
  53. package/src/lsp/types.ts +221 -0
  54. package/src/watch/daemon.ts +385 -0
  55. package/src/watch/lockfile-detector.ts +65 -0
  56. package/src/watch/paths.ts +279 -0
  57. package/src/watch/state.ts +178 -0
@@ -0,0 +1,776 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Plan 3b — Phase 4: Minimal JSON-RPC LSP client.
6
+ *
7
+ * Methods supported: `initialize`, `textDocument/documentSymbol`,
8
+ * `workspace/symbol`, `textDocument/definition`, `shutdown`.
9
+ *
10
+ * Wire transport: stdio JSON-RPC via `child_process.spawn` with the
11
+ * `Content-Length: N\r\n\r\n<body>` framing required by LSP.
12
+ *
13
+ * Security guarantees:
14
+ * - `command` MUST be a pre-split `[argv0, ...args]` array (no shell). The
15
+ * factory rejects shell-string input — the caller in `auto-detect.ts`
16
+ * splits commands safely. (Phase 3.5 finding #4)
17
+ * - Refuses paths containing `..`. Refuses non-absolute paths unless
18
+ * `allowRelativePath: true`.
19
+ * - Per-server method-support matrix: capabilities checked from the
20
+ * `initialize` response; methods whose `*Provider` capability is
21
+ * absent/false are SKIPPED (not sent). (audit-iter-2 fix N6)
22
+ * - MethodNotFound (-32601) for a method we did send → that single
23
+ * capability is marked unavailable for the lifetime of this client
24
+ * instance.
25
+ * - Every response payload is validated against the Zod schema from
26
+ * `types.ts` before the consumer sees it. Validation failure logs to
27
+ * stderr (per VR-USER-ERROR-MESSAGES item 2) and returns null.
28
+ * - 5s per-request timeout. On timeout: log info, return null.
29
+ * - Max body size 5MB. Oversized → log warning, abort, return null.
30
+ * - Mismatched response ids (response-injection) are silently dropped.
31
+ *
32
+ * Library purity: never terminates the process; never touches the memory DB.
33
+ * ESM imports throughout.
34
+ */
35
+
36
+ import { spawn, spawnSync, type ChildProcess } from 'child_process';
37
+ import { lstatSync, realpathSync } from 'fs';
38
+ import { isAbsolute, resolve as resolvePath } from 'path';
39
+ import {
40
+ DefinitionResponseSchema,
41
+ DocumentSymbolResponseSchema,
42
+ InitializeResponseSchema,
43
+ LSPErrorCode,
44
+ LSPMessageEnvelopeSchema,
45
+ WorkspaceSymbolResponseSchema,
46
+ type DefinitionResponse,
47
+ type DocumentSymbolResponse,
48
+ type InitializeResponse,
49
+ type Position,
50
+ type ServerCapabilities,
51
+ type WorkspaceSymbolResponse,
52
+ } from './types.ts';
53
+
54
+ /**
55
+ * Maximum body size (bytes) for any LSP response. Protection against memory
56
+ * exhaustion via oversized responses (Phase 3.5 finding #2).
57
+ */
58
+ const MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
59
+ /** Default per-request timeout (ms). LSP unresponsive → degrade to AST-only. */
60
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
61
+ /**
62
+ * Maximum cumulative bytes the parser will buffer waiting for a header to
63
+ * arrive. A malicious LSP that drips characters forever without ever
64
+ * producing `\r\n\r\n` would otherwise grow the inbound buffer unbounded.
65
+ * 1MB is far more than any legitimate header. (Phase 3.5 finding #2)
66
+ */
67
+ const MAX_HEADER_BUFFER_BYTES = 1 * 1024 * 1024;
68
+
69
+ /**
70
+ * Strip prototype-pollution keys from any object before it crosses the
71
+ * trust boundary. Zod's `.passthrough()` accepts arbitrary keys including
72
+ * `__proto__` and `constructor.prototype`; we sanitise here so consumers
73
+ * never observe a polluted object. (Phase 3.5 finding #2 — prototype
74
+ * pollution.)
75
+ */
76
+ function sanitizePolluted(value: unknown): unknown {
77
+ if (value === null || typeof value !== 'object') return value;
78
+ if (Array.isArray(value)) {
79
+ return value.map(sanitizePolluted);
80
+ }
81
+ const out: Record<string, unknown> = {};
82
+ for (const k of Object.keys(value as Record<string, unknown>)) {
83
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
84
+ out[k] = sanitizePolluted((value as Record<string, unknown>)[k]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // ============================================================
90
+ // Transport contract — pluggable for tests
91
+ // ============================================================
92
+
93
+ /**
94
+ * In-memory transport interface. Tests inject a stub; production wires the
95
+ * stdio of a spawned LSP process. The contract is:
96
+ * - `send(jsonText)`: client → server; framed by the transport.
97
+ * - `onMessage(fn)`: server → client; one parsed envelope per call.
98
+ * - `close()`: terminate cleanly.
99
+ */
100
+ export interface LSPTransport {
101
+ send(json: string): void;
102
+ onMessage(handler: (envelope: unknown) => void): void;
103
+ onError(handler: (err: Error) => void): void;
104
+ close(): void;
105
+ }
106
+
107
+ /**
108
+ * Stdio-framed transport over a spawned child process. Produced by
109
+ * `LSPClient.fromCommand()` for production use; tests use `LSPClient.with(...)`.
110
+ */
111
+ function createStdioTransport(child: ChildProcess): LSPTransport {
112
+ let messageHandler: ((env: unknown) => void) | null = null;
113
+ let errorHandler: ((err: Error) => void) | null = null;
114
+ let buffer = Buffer.alloc(0);
115
+
116
+ const stdout = child.stdout;
117
+ const stdin = child.stdin;
118
+ if (!stdout || !stdin) {
119
+ throw new Error('LSP child process is missing stdio handles');
120
+ }
121
+
122
+ stdout.on('data', (chunk: Buffer) => {
123
+ buffer = Buffer.concat([buffer, chunk]);
124
+ while (buffer.length > 0) {
125
+ // Parse `Content-Length: N\r\n\r\n<body>` framing.
126
+ const headerEnd = buffer.indexOf('\r\n\r\n');
127
+ if (headerEnd === -1) {
128
+ // No complete header yet. Cap buffer growth so a malicious LSP
129
+ // that drips bytes without `\r\n\r\n` cannot exhaust memory.
130
+ if (buffer.length > MAX_HEADER_BUFFER_BYTES) {
131
+ process.stderr.write(
132
+ `[massu/lsp] WARN: header buffer exceeded ${MAX_HEADER_BUFFER_BYTES} bytes without framing — dropping. (Phase 3.5 mitigation)\n`,
133
+ );
134
+ buffer = Buffer.alloc(0);
135
+ }
136
+ return;
137
+ }
138
+ const headerText = buffer.subarray(0, headerEnd).toString('utf-8');
139
+ const match = /Content-Length:\s*(\d+)/i.exec(headerText);
140
+ if (!match) {
141
+ // Malformed framing — drop everything and continue (server may be
142
+ // emitting non-LSP chatter on stdout; LSP says it shouldn't, but be
143
+ // forgiving).
144
+ buffer = buffer.subarray(headerEnd + 4);
145
+ continue;
146
+ }
147
+ const len = parseInt(match[1] ?? '0', 10);
148
+ if (Number.isNaN(len) || len < 0) {
149
+ buffer = buffer.subarray(headerEnd + 4);
150
+ continue;
151
+ }
152
+ if (len > MAX_RESPONSE_BODY_BYTES) {
153
+ // Oversized — log and drop (don't try to read it).
154
+ process.stderr.write(
155
+ `[massu/lsp] WARN: oversized LSP response body (${len} > ${MAX_RESPONSE_BODY_BYTES} bytes) — dropping. (Phase 3.5 mitigation)\n`
156
+ );
157
+ // Skip the header + body; still need len bytes available before we
158
+ // can drop them. If not all here yet, wait — but cap waiting by
159
+ // returning early and letting the next `data` event re-enter.
160
+ if (buffer.length < headerEnd + 4 + len) return;
161
+ buffer = buffer.subarray(headerEnd + 4 + len);
162
+ continue;
163
+ }
164
+ if (buffer.length < headerEnd + 4 + len) return;
165
+ const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + len).toString('utf-8');
166
+ buffer = buffer.subarray(headerEnd + 4 + len);
167
+
168
+ let parsed: unknown;
169
+ try {
170
+ parsed = JSON.parse(body);
171
+ } catch (e) {
172
+ if (errorHandler) errorHandler(e instanceof Error ? e : new Error(String(e)));
173
+ continue;
174
+ }
175
+ if (messageHandler) messageHandler(parsed);
176
+ }
177
+ });
178
+
179
+ stdout.on('error', (err: Error) => {
180
+ if (errorHandler) errorHandler(err);
181
+ });
182
+ stdin.on('error', (err: Error) => {
183
+ if (errorHandler) errorHandler(err);
184
+ });
185
+ child.on('error', (err: Error) => {
186
+ if (errorHandler) errorHandler(err);
187
+ });
188
+
189
+ return {
190
+ send(json: string) {
191
+ const body = Buffer.from(json, 'utf-8');
192
+ const header = `Content-Length: ${body.length}\r\n\r\n`;
193
+ stdin.write(header + json);
194
+ },
195
+ onMessage(fn) {
196
+ messageHandler = fn;
197
+ },
198
+ onError(fn) {
199
+ errorHandler = fn;
200
+ },
201
+ close() {
202
+ try { stdin.end(); } catch { /* ignore */ }
203
+ try { child.kill(); } catch { /* ignore */ }
204
+ },
205
+ };
206
+ }
207
+
208
+ // ============================================================
209
+ // LSP server spec (config -> client factory input)
210
+ // ============================================================
211
+
212
+ export interface LSPServerSpec {
213
+ /** Logical language name (matches `lsp.servers[].language`). */
214
+ language: string;
215
+ /** Pre-split argv. First element is the executable path. */
216
+ argv: string[];
217
+ /** When true, allow non-absolute argv[0]. Default false (security). */
218
+ allowRelativePath?: boolean;
219
+ /**
220
+ * F-014 (closed 2026-05-06): when true, allow argv[0] to be a SUID/SGID
221
+ * binary (or symlink resolving to one). Default false. SUID binaries
222
+ * inherit elevated privileges from the kernel at exec time; Node has no
223
+ * post-spawn way to strip them. The user-trust boundary is at config
224
+ * time, but a defensive lstat catches accidental misconfigs (e.g.
225
+ * pointing argv[0] at a system tool).
226
+ */
227
+ allowSetuid?: boolean;
228
+ /**
229
+ * F-015 (closed 2026-05-06): RSS budget in MB. The watchdog polls
230
+ * `ps -p <pid> -o rss=` every WATCHDOG_INTERVAL_MS and SIGKILLs the
231
+ * child if RSS exceeds this budget for two consecutive samples.
232
+ * Default 1024 (1 GB). Set to 0 to disable the watchdog.
233
+ */
234
+ maxRssMb?: number;
235
+ }
236
+
237
+ // ============================================================
238
+ // Typed errors — F-014, F-015 (closed 2026-05-06)
239
+ // ============================================================
240
+
241
+ /**
242
+ * Thrown by `LSPClient.fromCommand` when argv[0] (or its symlink target)
243
+ * has the SUID/SGID bit set and `spec.allowSetuid: true` was not opted in.
244
+ *
245
+ * Why throw rather than silently accept: SUID binaries inherit elevated
246
+ * privileges from the kernel at exec time. Node cannot strip them
247
+ * post-spawn. A user who wants this MUST opt in explicitly so the
248
+ * decision is auditable in their config.
249
+ */
250
+ export class LspBinaryIsSetuidError extends Error {
251
+ public readonly path: string;
252
+ public readonly mode: number;
253
+ constructor(path: string, mode: number) {
254
+ super(
255
+ `LSPClient.fromCommand: refused SUID/SGID binary at "${path}" ` +
256
+ `(mode=${mode.toString(8)}). The kernel will exec this with ` +
257
+ `elevated privileges; Node cannot strip that post-spawn. ` +
258
+ `Set spec.allowSetuid: true to opt in (auditable in config).`,
259
+ );
260
+ this.name = 'LspBinaryIsSetuidError';
261
+ this.path = path;
262
+ this.mode = mode;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Constants for the F-015 RSS watchdog. Exported so tests can inspect
268
+ * (and so a future config can override per-deployment if needed).
269
+ */
270
+ export const DEFAULT_LSP_MAX_RSS_MB = 1024;
271
+ export const LSP_WATCHDOG_INTERVAL_MS = 30_000;
272
+ /**
273
+ * Number of consecutive over-budget samples required before SIGKILL.
274
+ * Avoids killing a server that briefly spikes during indexing — only
275
+ * sustained over-budget triggers eviction.
276
+ */
277
+ export const LSP_WATCHDOG_OVERBUDGET_SAMPLES = 2;
278
+
279
+ /**
280
+ * F-014 helper: detect SUID/SGID bits on a file. Follows the chain via
281
+ * lstat then statSync(realpath) so a symlink to a SUID binary is also
282
+ * caught. Returns null if the file doesn't exist or the stat fails.
283
+ *
284
+ * Bit semantics (per stat(2)):
285
+ * - 0o4000 = SUID (set-user-ID on execution)
286
+ * - 0o2000 = SGID (set-group-ID on execution)
287
+ */
288
+ export function _detectSetuid(path: string): { hasSetuid: boolean; mode: number; resolvedPath: string } | null {
289
+ // First lstat — if argv[0] itself is a symlink, follow it via realpath.
290
+ let resolved = path;
291
+ try {
292
+ const linkStat = lstatSync(path);
293
+ if (linkStat.isSymbolicLink()) {
294
+ resolved = realpathSync(path);
295
+ }
296
+ } catch {
297
+ return null;
298
+ }
299
+ // Now stat the resolved (non-symlink) target.
300
+ try {
301
+ const targetStat = lstatSync(resolved);
302
+ const mode = targetStat.mode;
303
+ return {
304
+ hasSetuid: (mode & 0o4000) !== 0 || (mode & 0o2000) !== 0,
305
+ mode,
306
+ resolvedPath: resolved,
307
+ };
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * F-015 helper: probe a child's RSS in MB via `ps -p <pid> -o rss=`.
315
+ * Returns null if ps fails (e.g., process already gone, or non-POSIX
316
+ * platform without ps). Best-effort — watchdog treats null as "no
317
+ * sample, don't count toward over-budget streak."
318
+ */
319
+ export function _probeChildRssMb(pid: number): number | null {
320
+ try {
321
+ const result = spawnSync('ps', ['-o', 'rss=', '-p', String(pid)], {
322
+ encoding: 'utf-8',
323
+ timeout: 5_000,
324
+ });
325
+ if (result.status !== 0 || !result.stdout) return null;
326
+ const rssKb = parseInt(result.stdout.trim(), 10);
327
+ if (!Number.isFinite(rssKb) || rssKb < 0) return null;
328
+ return Math.round((rssKb / 1024) * 10) / 10;
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * F-015 helper: install an interval-based RSS watchdog on a spawned child.
336
+ * Returns the watchdog handle (interval id + cleanup) so the caller can
337
+ * stop it on transport shutdown / process exit.
338
+ *
339
+ * The watchdog SIGKILLs the child if RSS exceeds the budget for
340
+ * `LSP_WATCHDOG_OVERBUDGET_SAMPLES` consecutive samples. Killing emits a
341
+ * stderr warning naming the LSP language and the breach.
342
+ */
343
+ export function _startRssWatchdog(
344
+ child: ChildProcess,
345
+ language: string,
346
+ maxRssMb: number,
347
+ intervalMs: number = LSP_WATCHDOG_INTERVAL_MS,
348
+ ): { stop: () => void } {
349
+ if (maxRssMb <= 0) return { stop: () => { /* disabled */ } };
350
+ let overBudgetStreak = 0;
351
+ const tick = (): void => {
352
+ if (!child.pid || child.killed || child.exitCode !== null) return;
353
+ const rss = _probeChildRssMb(child.pid);
354
+ if (rss === null) return; // no sample; don't penalise
355
+ if (rss > maxRssMb) {
356
+ overBudgetStreak += 1;
357
+ process.stderr.write(
358
+ `[massu/lsp] WARN: ${language} server RSS=${rss}MB > budget ${maxRssMb}MB ` +
359
+ `(streak=${overBudgetStreak}/${LSP_WATCHDOG_OVERBUDGET_SAMPLES})\n`,
360
+ );
361
+ if (overBudgetStreak >= LSP_WATCHDOG_OVERBUDGET_SAMPLES) {
362
+ process.stderr.write(
363
+ `[massu/lsp] KILLING ${language} server pid=${child.pid}: ` +
364
+ `sustained RSS over budget. (F-015 watchdog)\n`,
365
+ );
366
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
367
+ clearInterval(handle);
368
+ }
369
+ } else {
370
+ overBudgetStreak = 0;
371
+ }
372
+ };
373
+ const handle = setInterval(tick, intervalMs);
374
+ // Don't keep the event loop alive solely for the watchdog.
375
+ if (typeof handle.unref === 'function') handle.unref();
376
+ return {
377
+ stop: () => clearInterval(handle),
378
+ };
379
+ }
380
+
381
+ // ============================================================
382
+ // LSPClient
383
+ // ============================================================
384
+
385
+ interface PendingRequest {
386
+ resolve: (value: unknown) => void;
387
+ reject: (reason: Error) => void;
388
+ timer: NodeJS.Timeout;
389
+ method: string;
390
+ }
391
+
392
+ /**
393
+ * Optional client configuration. `requestTimeoutMs` lets tests run timeout
394
+ * scenarios without waiting the full 5s default; production callers should
395
+ * always use the default.
396
+ */
397
+ export interface LSPClientOptions {
398
+ requestTimeoutMs?: number;
399
+ }
400
+
401
+ /**
402
+ * Minimal LSP client. Construct via `LSPClient.fromCommand(spec)` for the
403
+ * production stdio path, or `LSPClient.with(transport)` for tests.
404
+ */
405
+ export class LSPClient {
406
+ private nextId = 1;
407
+ private pending = new Map<number, PendingRequest>();
408
+ private capabilities: ServerCapabilities = {};
409
+ private initialized = false;
410
+ /** Methods that returned MethodNotFound at runtime — never call again. */
411
+ private deadMethods = new Set<string>();
412
+ private closed = false;
413
+ private requestTimeoutMs: number;
414
+
415
+ private constructor(private transport: LSPTransport, options: LSPClientOptions = {}) {
416
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
417
+ this.transport.onMessage((env) => this.handleMessage(env));
418
+ this.transport.onError((err) => {
419
+ // Errors are non-fatal; pending requests resolve null on timeout.
420
+ process.stderr.write(`[massu/lsp] WARN: transport error: ${err.message}\n`);
421
+ });
422
+ }
423
+
424
+ /**
425
+ * Wire a pre-built transport (used by tests that swap stdin/stdout for an
426
+ * in-memory shim). Production callers should use `fromCommand`.
427
+ */
428
+ static with(transport: LSPTransport, options: LSPClientOptions = {}): LSPClient {
429
+ return new LSPClient(transport, options);
430
+ }
431
+
432
+ /**
433
+ * Spawn the LSP server via `child_process.spawn` with argv array form
434
+ * (NEVER a shell string).
435
+ *
436
+ * Security:
437
+ * - `spec.argv` MUST be a pre-split array. We don't accept a shell-string
438
+ * `command` field — the caller pre-splits it.
439
+ * - argv[0] MUST be absolute unless `spec.allowRelativePath === true`.
440
+ * - argv[0] MUST NOT contain `..`.
441
+ * - Any argv element MUST NOT contain `..` (defense in depth).
442
+ */
443
+ static fromCommand(spec: LSPServerSpec, options: LSPClientOptions = {}): LSPClient {
444
+ if (!Array.isArray(spec.argv) || spec.argv.length === 0) {
445
+ throw new Error('LSPClient.fromCommand: spec.argv must be a non-empty array');
446
+ }
447
+ const exe = spec.argv[0];
448
+ if (typeof exe !== 'string' || exe.length === 0) {
449
+ throw new Error('LSPClient.fromCommand: spec.argv[0] (executable) must be a non-empty string');
450
+ }
451
+ for (const a of spec.argv) {
452
+ if (typeof a !== 'string') {
453
+ throw new Error('LSPClient.fromCommand: every spec.argv element must be a string');
454
+ }
455
+ if (a.includes('..')) {
456
+ throw new Error(`LSPClient.fromCommand: refused argv element containing "..": ${a}`);
457
+ }
458
+ // Phase 3.5 finding #4 — null-byte injection. Node's spawn refuses
459
+ // strings containing NUL on most platforms, but we add an explicit
460
+ // check so the failure is descriptive and can never be silently
461
+ // mishandled by a kernel-level argv split.
462
+ if (a.includes('\0')) {
463
+ throw new Error(`LSPClient.fromCommand: refused argv element containing NUL byte`);
464
+ }
465
+ }
466
+ if (!spec.allowRelativePath && !isAbsolute(exe)) {
467
+ throw new Error(
468
+ `LSPClient.fromCommand: refused non-absolute executable "${exe}". ` +
469
+ `Pass an absolute path or set allowRelativePath: true to opt in.`
470
+ );
471
+ }
472
+
473
+ // F-014 (closed 2026-05-06): SUID/SGID bit detection. We only check
474
+ // when argv[0] is absolute (post the relative-path gate) — for
475
+ // allowRelativePath shapes the user has explicitly accepted that
476
+ // PATH-resolution semantics apply, including any SUID a binary
477
+ // resolved from PATH might have. Resolve the path to an absolute
478
+ // form so the lstat target is unambiguous.
479
+ if (!spec.allowSetuid) {
480
+ const absExe = isAbsolute(exe) ? exe : resolvePath(exe);
481
+ const det = _detectSetuid(absExe);
482
+ if (det !== null && det.hasSetuid) {
483
+ throw new LspBinaryIsSetuidError(det.resolvedPath, det.mode);
484
+ }
485
+ }
486
+
487
+ const child = spawn(exe, spec.argv.slice(1), {
488
+ stdio: ['pipe', 'pipe', 'pipe'],
489
+ // Explicitly NO `shell: true` — argv array form is the security
490
+ // contract.
491
+ shell: false,
492
+ // Phase 3.5 finding #4: drop the parent's environment so the
493
+ // spawned LSP cannot leak ambient secrets via env. Carry only PATH
494
+ // (LSP servers commonly expect it for resolving sub-tools) and
495
+ // HOME (some servers use it for cache directories).
496
+ env: {
497
+ PATH: process.env.PATH ?? '',
498
+ HOME: process.env.HOME ?? '',
499
+ LANG: process.env.LANG ?? 'C.UTF-8',
500
+ },
501
+ });
502
+
503
+ // F-015 (closed 2026-05-06): RSS watchdog. Polls every 30s, kills
504
+ // child after sustained over-budget. Disabled when maxRssMb === 0.
505
+ const maxRssMb = spec.maxRssMb ?? DEFAULT_LSP_MAX_RSS_MB;
506
+ const watchdog = _startRssWatchdog(child, spec.language, maxRssMb);
507
+
508
+ // Stop the watchdog when the child exits naturally so the interval
509
+ // doesn't outlive the process.
510
+ child.once('exit', () => watchdog.stop());
511
+ child.once('error', () => watchdog.stop());
512
+
513
+ return new LSPClient(createStdioTransport(child), options);
514
+ }
515
+
516
+ // --------------------------------------------------------
517
+ // Public API
518
+ // --------------------------------------------------------
519
+
520
+ /**
521
+ * Initialize the server. Stores `ServerCapabilities` for later capability
522
+ * gating. Returns null on timeout / Zod validation failure.
523
+ */
524
+ async initialize(rootUri: string | null = null): Promise<InitializeResponse | null> {
525
+ const params = {
526
+ processId: process.pid,
527
+ rootUri,
528
+ capabilities: {},
529
+ };
530
+ const raw = await this.sendRequest('initialize', params);
531
+ if (raw === null) return null;
532
+ const parsed = InitializeResponseSchema.safeParse(sanitizePolluted(raw));
533
+ if (!parsed.success) {
534
+ process.stderr.write(
535
+ `[massu/lsp] WARN: initialize response failed Zod validation: ${parsed.error.message}\n`
536
+ );
537
+ return null;
538
+ }
539
+ this.capabilities = parsed.data.capabilities;
540
+ this.initialized = true;
541
+ // LSP requires a notification after initialize.
542
+ this.sendNotification('initialized', {});
543
+ return parsed.data;
544
+ }
545
+
546
+ /**
547
+ * Document symbols for a single file. Returns null when:
548
+ * - capability `documentSymbolProvider` is false/absent (skip request)
549
+ * - method previously returned MethodNotFound
550
+ * - timeout
551
+ * - Zod validation failure
552
+ */
553
+ async documentSymbol(uri: string): Promise<DocumentSymbolResponse | null> {
554
+ if (!this.checkCapability('documentSymbolProvider', 'textDocument/documentSymbol')) {
555
+ return null;
556
+ }
557
+ const raw = await this.sendRequest('textDocument/documentSymbol', {
558
+ textDocument: { uri },
559
+ });
560
+ if (raw === null) return null;
561
+ const parsed = DocumentSymbolResponseSchema.safeParse(sanitizePolluted(raw));
562
+ if (!parsed.success) {
563
+ process.stderr.write(
564
+ `[massu/lsp] WARN: textDocument/documentSymbol response failed Zod validation — falling back to AST-only for this file. (${parsed.error.message})\n`
565
+ );
566
+ return null;
567
+ }
568
+ return parsed.data;
569
+ }
570
+
571
+ /**
572
+ * Workspace symbol search. Returns null when capability is missing/false
573
+ * (e.g., sourcekit-lsp's `workspaceSymbolProvider: false` per plan line 151
574
+ * — empty result is INCONCLUSIVE; we don't even send the request).
575
+ */
576
+ async workspaceSymbol(query: string): Promise<WorkspaceSymbolResponse | null> {
577
+ if (!this.checkCapability('workspaceSymbolProvider', 'workspace/symbol')) {
578
+ return null;
579
+ }
580
+ const raw = await this.sendRequest('workspace/symbol', { query });
581
+ if (raw === null) return null;
582
+ const parsed = WorkspaceSymbolResponseSchema.safeParse(sanitizePolluted(raw));
583
+ if (!parsed.success) {
584
+ process.stderr.write(
585
+ `[massu/lsp] WARN: workspace/symbol response failed Zod validation. (${parsed.error.message})\n`
586
+ );
587
+ return null;
588
+ }
589
+ return parsed.data;
590
+ }
591
+
592
+ /** Resolve a symbol's defining location. */
593
+ async definition(uri: string, position: Position): Promise<DefinitionResponse | null> {
594
+ if (!this.checkCapability('definitionProvider', 'textDocument/definition')) {
595
+ return null;
596
+ }
597
+ const raw = await this.sendRequest('textDocument/definition', {
598
+ textDocument: { uri },
599
+ position,
600
+ });
601
+ if (raw === null) return null;
602
+ const parsed = DefinitionResponseSchema.safeParse(sanitizePolluted(raw));
603
+ if (!parsed.success) {
604
+ process.stderr.write(
605
+ `[massu/lsp] WARN: textDocument/definition response failed Zod validation. (${parsed.error.message})\n`
606
+ );
607
+ return null;
608
+ }
609
+ return parsed.data;
610
+ }
611
+
612
+ /** Send `shutdown` then `exit`, then close transport. Idempotent. */
613
+ async shutdown(): Promise<void> {
614
+ if (this.closed) return;
615
+ this.closed = true;
616
+ try {
617
+ // Best-effort — don't block on shutdown if the server is unresponsive.
618
+ await Promise.race([
619
+ this.sendRequest('shutdown', null),
620
+ new Promise((r) => setTimeout(r, 1000)),
621
+ ]);
622
+ } catch {
623
+ /* ignore */
624
+ }
625
+ try {
626
+ this.sendNotification('exit', null);
627
+ } catch {
628
+ /* ignore */
629
+ }
630
+ // Reject all in-flight requests.
631
+ for (const [id, p] of this.pending) {
632
+ clearTimeout(p.timer);
633
+ p.resolve(null);
634
+ this.pending.delete(id);
635
+ }
636
+ this.transport.close();
637
+ }
638
+
639
+ /** Read-only view of captured capabilities (post-initialize). */
640
+ getCapabilities(): ServerCapabilities {
641
+ return { ...this.capabilities };
642
+ }
643
+
644
+ // --------------------------------------------------------
645
+ // Internals
646
+ // --------------------------------------------------------
647
+
648
+ /**
649
+ * Returns true if the method should be sent. Returns false (and the caller
650
+ * returns null) when the capability is missing/false OR was previously
651
+ * marked dead via MethodNotFound.
652
+ */
653
+ private checkCapability(
654
+ capabilityName: keyof ServerCapabilities,
655
+ method: string
656
+ ): boolean {
657
+ if (this.deadMethods.has(method)) return false;
658
+ if (!this.initialized) {
659
+ // Pre-initialize calls are programmer errors — don't crash, just skip.
660
+ process.stderr.write(
661
+ `[massu/lsp] WARN: ${method} called before initialize() — skipping.\n`
662
+ );
663
+ return false;
664
+ }
665
+ const cap = this.capabilities[capabilityName];
666
+ // `*Provider: true | { ...options }` → supported. `false | undefined` → not.
667
+ if (cap === undefined || cap === false) return false;
668
+ return true;
669
+ }
670
+
671
+ /**
672
+ * Send a JSON-RPC request and resolve with the `result` field (raw, not yet
673
+ * Zod-validated). Returns null on timeout, MethodNotFound (for graceful
674
+ * degrade), or any other LSP error.
675
+ */
676
+ private sendRequest(method: string, params: unknown): Promise<unknown> {
677
+ const id = this.nextId++;
678
+ const envelope = {
679
+ jsonrpc: '2.0' as const,
680
+ id,
681
+ method,
682
+ params,
683
+ };
684
+ return new Promise<unknown>((resolve) => {
685
+ const timer = setTimeout(() => {
686
+ this.pending.delete(id);
687
+ process.stderr.write(
688
+ `[massu/lsp] INFO: ${method} timed out after ${this.requestTimeoutMs}ms — degrading to AST-only for this field.\n`
689
+ );
690
+ resolve(null);
691
+ }, this.requestTimeoutMs);
692
+
693
+ this.pending.set(id, {
694
+ resolve: (value) => resolve(value),
695
+ reject: (err) => {
696
+ process.stderr.write(`[massu/lsp] WARN: ${method} rejected: ${err.message}\n`);
697
+ resolve(null);
698
+ },
699
+ timer,
700
+ method,
701
+ });
702
+
703
+ try {
704
+ this.transport.send(JSON.stringify(envelope));
705
+ } catch (e) {
706
+ clearTimeout(timer);
707
+ this.pending.delete(id);
708
+ process.stderr.write(
709
+ `[massu/lsp] WARN: failed to send ${method}: ${e instanceof Error ? e.message : String(e)}\n`
710
+ );
711
+ resolve(null);
712
+ }
713
+ });
714
+ }
715
+
716
+ /** Fire-and-forget notification (no response expected). */
717
+ private sendNotification(method: string, params: unknown): void {
718
+ const envelope = {
719
+ jsonrpc: '2.0' as const,
720
+ method,
721
+ params,
722
+ };
723
+ try {
724
+ this.transport.send(JSON.stringify(envelope));
725
+ } catch (e) {
726
+ process.stderr.write(
727
+ `[massu/lsp] WARN: notification ${method} failed to send: ${e instanceof Error ? e.message : String(e)}\n`
728
+ );
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Dispatch an inbound message. Validates the envelope, ignores rogue
734
+ * responses with mismatched ids (response-injection mitigation), and
735
+ * marks methods as dead on MethodNotFound.
736
+ */
737
+ private handleMessage(raw: unknown): void {
738
+ const env = LSPMessageEnvelopeSchema.safeParse(raw);
739
+ if (!env.success) {
740
+ process.stderr.write(
741
+ `[massu/lsp] WARN: ignored malformed LSP envelope: ${env.error.message}\n`
742
+ );
743
+ return;
744
+ }
745
+ const e = env.data;
746
+ if (e.id === undefined) {
747
+ // Notification — ignore (we don't subscribe to anything).
748
+ return;
749
+ }
750
+ if (typeof e.id !== 'number') {
751
+ // We only ever send numeric ids.
752
+ return;
753
+ }
754
+ const pending = this.pending.get(e.id);
755
+ if (!pending) {
756
+ // Mismatched id → response-injection or duplicate. Drop silently.
757
+ return;
758
+ }
759
+ clearTimeout(pending.timer);
760
+ this.pending.delete(e.id);
761
+
762
+ if (e.error) {
763
+ if (e.error.code === LSPErrorCode.MethodNotFound) {
764
+ // Mark this method dead for the lifetime of this client. Future calls
765
+ // short-circuit via `deadMethods` check.
766
+ this.deadMethods.add(pending.method);
767
+ process.stderr.write(
768
+ `[massu/lsp] INFO: server reported ${pending.method} not implemented — disabling for this session.\n`
769
+ );
770
+ }
771
+ pending.resolve(null);
772
+ return;
773
+ }
774
+ pending.resolve(e.result ?? null);
775
+ }
776
+ }