@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.
- package/commands/README.md +23 -11
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-scaffold-page.swift.md +10 -10
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/dist/cli.js +9914 -4133
- package/dist/hooks/auto-learning-pipeline.js +45 -2
- package/dist/hooks/classify-failure.js +45 -2
- package/dist/hooks/cost-tracker.js +45 -2
- package/dist/hooks/fix-detector.js +45 -2
- package/dist/hooks/incident-pipeline.js +45 -2
- package/dist/hooks/post-edit-context.js +45 -2
- package/dist/hooks/post-tool-use.js +45 -2
- package/dist/hooks/pre-compact.js +45 -2
- package/dist/hooks/pre-delete-check.js +45 -2
- package/dist/hooks/quality-event.js +45 -2
- package/dist/hooks/rule-enforcement-pipeline.js +45 -2
- package/dist/hooks/session-end.js +45 -2
- package/dist/hooks/session-start.js +4790 -406
- package/dist/hooks/user-prompt.js +45 -2
- package/package.json +13 -4
- package/src/cli.ts +22 -2
- package/src/commands/config-refresh.ts +91 -23
- package/src/commands/init.ts +131 -24
- package/src/commands/install-commands.ts +142 -26
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/template-engine.ts +260 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +71 -0
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +467 -0
- package/src/detect/adapters/types.ts +173 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +98 -0
- package/src/lsp/client.ts +776 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- 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
|
+
}
|