dsh-context-mode 0.1.2 → 0.2.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 (60) hide show
  1. package/LICENSING.md +37 -0
  2. package/README.md +40 -14
  3. package/lib/types/cjk.d.ts +54 -0
  4. package/lib/types/cjk.d.ts.map +1 -0
  5. package/lib/types/cjk.js +64 -0
  6. package/lib/types/index.d.ts.map +1 -1
  7. package/lib/types/index.js +71 -22
  8. package/lib/types/output-containment.d.ts +35 -0
  9. package/lib/types/output-containment.d.ts.map +1 -0
  10. package/lib/types/output-containment.js +103 -0
  11. package/lib/types/routing.d.ts +3 -1
  12. package/lib/types/routing.d.ts.map +1 -1
  13. package/lib/types/routing.js +81 -6
  14. package/lib/types/session-memory.d.ts.map +1 -1
  15. package/lib/types/session-memory.js +14 -3
  16. package/package.json +9 -5
  17. package/skills/context-mode/SKILL.md +104 -11
  18. package/vendor/context-mode/LICENSE +94 -0
  19. package/vendor/context-mode/server.bundle.mjs +1126 -0
  20. package/vendor/context-mode/src/cli.ts +2040 -0
  21. package/vendor/context-mode/src/db-base.ts +617 -0
  22. package/vendor/context-mode/src/executor.ts +785 -0
  23. package/vendor/context-mode/src/exit-classify.ts +33 -0
  24. package/vendor/context-mode/src/fetch-cache.ts +15 -0
  25. package/vendor/context-mode/src/lifecycle.ts +305 -0
  26. package/vendor/context-mode/src/platform/client-map.ts +45 -0
  27. package/vendor/context-mode/src/platform/detect.ts +645 -0
  28. package/vendor/context-mode/src/platform/dsh.ts +206 -0
  29. package/vendor/context-mode/src/platform/types.ts +503 -0
  30. package/vendor/context-mode/src/runPool.ts +81 -0
  31. package/vendor/context-mode/src/runtime.ts +765 -0
  32. package/vendor/context-mode/src/search/auto-memory.ts +200 -0
  33. package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
  34. package/vendor/context-mode/src/search/flood-guard.ts +111 -0
  35. package/vendor/context-mode/src/search/unified.ts +176 -0
  36. package/vendor/context-mode/src/security.ts +889 -0
  37. package/vendor/context-mode/src/server.ts +4991 -0
  38. package/vendor/context-mode/src/session/analytics.ts +3085 -0
  39. package/vendor/context-mode/src/session/db.ts +1726 -0
  40. package/vendor/context-mode/src/session/error-classifier.ts +392 -0
  41. package/vendor/context-mode/src/session/event-emit.ts +132 -0
  42. package/vendor/context-mode/src/session/extract.ts +2958 -0
  43. package/vendor/context-mode/src/session/index.ts +130 -0
  44. package/vendor/context-mode/src/session/model-prices.json +429 -0
  45. package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
  46. package/vendor/context-mode/src/session/pricing.ts +191 -0
  47. package/vendor/context-mode/src/session/project-attribution.ts +309 -0
  48. package/vendor/context-mode/src/session/purge.ts +338 -0
  49. package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
  50. package/vendor/context-mode/src/session/snapshot.ts +577 -0
  51. package/vendor/context-mode/src/store-directory.ts +290 -0
  52. package/vendor/context-mode/src/store.ts +2071 -0
  53. package/vendor/context-mode/src/truncate.ts +154 -0
  54. package/vendor/context-mode/src/types.ts +147 -0
  55. package/vendor/context-mode/src/util/claude-config.ts +95 -0
  56. package/vendor/context-mode/src/util/hook-config.ts +78 -0
  57. package/vendor/context-mode/src/util/jsonc.ts +70 -0
  58. package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
  59. package/vendor/context-mode/src/util/project-dir.ts +347 -0
  60. package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
@@ -0,0 +1,785 @@
1
+ import { spawn, execSync, execFileSync } from "node:child_process";
2
+ import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ detectRuntimes,
7
+ buildCommand,
8
+ type RuntimeMap,
9
+ type Language,
10
+ } from "./runtime.js";
11
+ export type { ExecResult } from "./types.js";
12
+ import type { ExecResult } from "./types.js";
13
+
14
+ const isWin = process.platform === "win32";
15
+
16
+ /**
17
+ * Pure helper: extension map for temp script files per language.
18
+ * On Windows, shell scripts usually get NO extension to avoid Windows
19
+ * file-association for `.sh` (which spawns a visible Git Bash window over the
20
+ * user's IDE). Windows PowerShell/pwsh is the exception because `-File`
21
+ * requires `.ps1` there.
22
+ */
23
+ const SCRIPT_EXT: Record<Language, string> = {
24
+ javascript: "js",
25
+ typescript: "ts",
26
+ python: "py",
27
+ shell: "sh",
28
+ ruby: "rb",
29
+ go: "go",
30
+ rust: "rs",
31
+ php: "php",
32
+ perl: "pl",
33
+ r: "R",
34
+ elixir: "exs",
35
+ csharp: "csx",
36
+ };
37
+
38
+ /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
39
+ export function buildScriptFilename(
40
+ language: Language,
41
+ platform: NodeJS.Platform,
42
+ shellPath?: string | null,
43
+ ): string {
44
+ if (platform === "win32" && language === "shell") {
45
+ const shellName = shellPath?.toLowerCase() ?? "";
46
+ if (shellName.includes("powershell") || shellName.includes("pwsh")) return "script.ps1";
47
+ const shellBase = shellName.split(/[\\/]/).pop() ?? shellName;
48
+ if (shellBase === "cmd" || shellBase === "cmd.exe") return "script.cmd";
49
+ return "script";
50
+ }
51
+ return `script.${SCRIPT_EXT[language]}`;
52
+ }
53
+
54
+ /**
55
+ * Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
56
+ * to prevent the spawned shell from creating a visible console window that
57
+ * intercepts stdout (issue #384).
58
+ */
59
+ export function buildSpawnOptions(platform: NodeJS.Platform): { windowsHide: boolean } {
60
+ return { windowsHide: platform === "win32" };
61
+ }
62
+
63
+ function quoteForPosixShell(value: string): string {
64
+ return `'${value.replace(/'/g, `'\\''`)}'`;
65
+ }
66
+
67
+ /** Pure helper — exported for unit testing. Restores parent PATH after shell startup. */
68
+ export function buildShellScriptContent(
69
+ code: string,
70
+ inheritedPath: string | undefined,
71
+ platform: NodeJS.Platform,
72
+ ): string {
73
+ if (platform === "win32" || !inheritedPath) return code;
74
+ return `export PATH=${quoteForPosixShell(inheritedPath)}\n${code}`;
75
+ }
76
+
77
+ function isPowerShell(shellPath: string | null | undefined): boolean {
78
+ const shellName = shellPath?.toLowerCase() ?? "";
79
+ return shellName.includes("powershell") || shellName.includes("pwsh");
80
+ }
81
+
82
+ export function buildPowerShellScriptContent(code: string): string {
83
+ // Prefix a UTF-8 BOM so Windows PowerShell 5.1 reliably detects the script
84
+ // file as UTF-8 (without it, 5.1 falls back to the ANSI code page and
85
+ // mangles non-ASCII characters in the script body).
86
+ return [
87
+ "\uFEFF[Console]::InputEncoding = [System.Text.UTF8Encoding]::new()",
88
+ "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()",
89
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new()",
90
+ code,
91
+ ].join("\n");
92
+ }
93
+
94
+ /**
95
+ * Resolve the real OS temp directory, bypassing any TMPDIR env override.
96
+ * os.tmpdir() reads TMPDIR from the environment, which some shells/tools
97
+ * set to the project root — causing temp files to pollute the working tree.
98
+ */
99
+ const OS_TMPDIR = (() => {
100
+ if (isWin) return process.env.TEMP ?? process.env.TMP ?? tmpdir();
101
+ try {
102
+ const result = execFileSync(
103
+ process.platform === "darwin" ? "getconf" : "mktemp",
104
+ process.platform === "darwin" ? ["DARWIN_USER_TEMP_DIR"] : ["-u", "-d"],
105
+ { env: { ...process.env, TMPDIR: undefined as unknown as string }, encoding: "utf-8" },
106
+ ).trim();
107
+ const dir = process.platform === "darwin" ? result : resolve(result, "..");
108
+ if (dir && dir !== process.cwd()) return dir;
109
+ } catch { /* fall through */ }
110
+ return "/tmp";
111
+ })();
112
+
113
+ /**
114
+ * Pure helper — exported for unit testing. Issue #782.
115
+ *
116
+ * On Windows, the sandbox shell runtime is Git Bash. A bare `mvn` invocation
117
+ * runs Maven's POSIX shell script, which on the `mingw=true` branch (uname →
118
+ * MINGW64_NT-*) fails to convert `CLASSWORLDS_JAR` from a POSIX path
119
+ * (`/c/tools/maven/boot/plexus-classworlds-*.jar`) to a Windows path. Native
120
+ * `java.exe` then can't resolve the bootstrap jar → ClassNotFoundException for
121
+ * `org.codehaus.plexus.classworlds.launcher.Launcher`.
122
+ *
123
+ * The third-way fix (issue Option C): rewrite the bare `mvn` token to `mvn.cmd`,
124
+ * the native Windows launcher that uses Windows-native paths and bypasses the
125
+ * broken mingw shell branch entirely. This does NOT touch the global MSYS
126
+ * path-conversion env (MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL), which #826/#791
127
+ * deliberately leave unset so native git.exe launched from bash keeps its
128
+ * /tmp→C:\ argument conversion. Re-enabling global suppression would re-break
129
+ * native git; rewriting only the mvn token keeps both correct.
130
+ *
131
+ * Only a `mvn` that starts a command (start of string, or after a shell
132
+ * separator `&& | ; ( newline`) is rewritten. `mvnw`, `mvnd`, `mymvn`,
133
+ * paths like `./mvnw`, and an already-`mvn.cmd` token are left untouched
134
+ * (the token must be exactly `mvn` followed by whitespace or end-of-string).
135
+ */
136
+ export function rewriteWindowsBuildTools(
137
+ code: string,
138
+ platform: NodeJS.Platform,
139
+ ): string {
140
+ if (platform !== "win32") return code;
141
+ // Rewrite a bare `mvn` command token to `mvn.cmd` (Maven's native Windows launcher).
142
+ // Algorithmic (no regex): only at a command-start position (string start or right
143
+ // after a shell separator ; & | ( newline, skipping leading spaces/tabs) and only
144
+ // when the token is exactly `mvn` followed by whitespace or end — leaves
145
+ // mvnw / mvnd / ./mvnw / already-mvn.cmd untouched.
146
+ const SEP = new Set([";", "&", "|", "(", "\n"]);
147
+ let out = "";
148
+ let atStart = true;
149
+ let i = 0;
150
+ while (i < code.length) {
151
+ const ch = code[i];
152
+ if (atStart && (ch === " " || ch === "\t")) {
153
+ out += ch;
154
+ i++;
155
+ continue;
156
+ }
157
+ if (atStart && code.startsWith("mvn", i)) {
158
+ const after = code[i + 3];
159
+ if (after === undefined || after === " " || after === "\t" || after === "\n") {
160
+ out += "mvn.cmd";
161
+ i += 3;
162
+ atStart = false;
163
+ continue;
164
+ }
165
+ }
166
+ out += ch;
167
+ atStart = SEP.has(ch);
168
+ i++;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Remove a sandbox temp dir, retrying on Windows. Issue #788.
175
+ *
176
+ * On Windows, a child process that opened SQLite databases inside the sandbox
177
+ * can leave `*-wal` / `*-shm` files with handles that linger briefly after the
178
+ * process exits. A single `rmSync` then throws EBUSY/EPERM/ENOTEMPTY and the
179
+ * old silent `catch {}` swallowed it, leaking `.ctx-mode-*` directories under
180
+ * `%TEMP%`. Node's `rmSync({ maxRetries, retryDelay })` is purpose-built for
181
+ * exactly this Windows-handle race, so let it back off and retry.
182
+ */
183
+ function cleanupTmpDir(tmpDir: string): void {
184
+ try {
185
+ rmSync(tmpDir, {
186
+ recursive: true,
187
+ force: true,
188
+ maxRetries: isWin ? 8 : 2,
189
+ retryDelay: 100,
190
+ });
191
+ } catch {
192
+ /* best-effort — OS will reclaim %TEMP% eventually */
193
+ }
194
+ }
195
+
196
+ /** Kill process tree — on Windows uses taskkill /T; on Unix kills the process group. */
197
+ function killTree(proc: ReturnType<typeof spawn>): void {
198
+ if (isWin && proc.pid) {
199
+ try {
200
+ execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: "pipe" });
201
+ } catch { /* already dead */ }
202
+ } else if (proc.pid) {
203
+ try {
204
+ // Kill entire process group (negative PID) to prevent orphaned children
205
+ process.kill(-proc.pid, "SIGKILL");
206
+ } catch { /* already dead */ }
207
+ }
208
+ }
209
+
210
+ interface ExecuteOptions {
211
+ language: Language;
212
+ code: string;
213
+ timeout?: number;
214
+ /** Keep process running after timeout instead of killing it. */
215
+ background?: boolean;
216
+ /**
217
+ * Issue #45 — per-call cwd override for the shell language. When set,
218
+ * the shell script runs in this directory instead of `#projectRoot`.
219
+ * Non-shell languages keep their tmpDir sandbox cwd regardless (the
220
+ * script file lives there). Used by Codex MCP handlers to pin shell
221
+ * commands to a resolved project root when the spawning host inherited
222
+ * a non-project cwd (e.g. $HOME).
223
+ */
224
+ cwd?: string;
225
+ }
226
+
227
+ interface ExecuteFileOptions extends ExecuteOptions {
228
+ path: string;
229
+ }
230
+
231
+ export class PolyglotExecutor {
232
+ #hardCapBytes: number;
233
+ /**
234
+ * Resolves the project root on every access. Stored as a thunk so the
235
+ * executor stays in sync with server-side env-cascade resolvers (e.g.
236
+ * `getProjectDir` in server.ts) instead of capturing a snapshot of
237
+ * `CLAUDE_PROJECT_DIR` at construction time. String inputs are wrapped
238
+ * to preserve constructor backward compatibility.
239
+ */
240
+ #projectRootResolver: () => string;
241
+ #runtimes: RuntimeMap;
242
+
243
+ /** PIDs of backgrounded processes — killed on cleanup to prevent zombies. */
244
+ #backgroundedPids = new Set<number>();
245
+
246
+ constructor(opts?: {
247
+ hardCapBytes?: number;
248
+ projectRoot?: string | (() => string);
249
+ runtimes?: RuntimeMap;
250
+ }) {
251
+ this.#hardCapBytes = opts?.hardCapBytes ?? 100 * 1024 * 1024; // 100MB
252
+ const pr = opts?.projectRoot;
253
+ if (typeof pr === "function") {
254
+ this.#projectRootResolver = pr;
255
+ } else if (typeof pr === "string") {
256
+ this.#projectRootResolver = () => pr;
257
+ } else {
258
+ this.#projectRootResolver = () => process.cwd();
259
+ }
260
+ this.#runtimes = opts?.runtimes ?? detectRuntimes();
261
+ }
262
+
263
+ get #projectRoot(): string {
264
+ return this.#projectRootResolver();
265
+ }
266
+
267
+ get runtimes(): RuntimeMap {
268
+ return { ...this.#runtimes };
269
+ }
270
+
271
+ /** Kill all backgrounded processes to prevent zombie/port-conflict issues. */
272
+ cleanupBackgrounded(): void {
273
+ for (const pid of this.#backgroundedPids) {
274
+ try {
275
+ // Kill process group on Unix to catch all children
276
+ process.kill(isWin ? pid : -pid, "SIGTERM");
277
+ } catch { /* already dead */ }
278
+ }
279
+ this.#backgroundedPids.clear();
280
+ }
281
+
282
+ async execute(opts: ExecuteOptions): Promise<ExecResult> {
283
+ const { language, code, timeout, background = false, cwd: cwdOverride } = opts;
284
+ const tmpDir = mkdtempSync(join(OS_TMPDIR, ".ctx-mode-"));
285
+
286
+ try {
287
+ const filePath = this.#writeScript(tmpDir, code, language);
288
+ const cmd = buildCommand(this.#runtimes, language, filePath);
289
+
290
+ // Rust: compile then run
291
+ if (cmd[0] === "__rust_compile_run__") {
292
+ return await this.#compileAndRun(filePath, tmpDir, timeout);
293
+ }
294
+
295
+ // Every language runs in the project directory so git, relative paths,
296
+ // and other project-aware tools resolve naturally. The script FILE lives
297
+ // in the sandbox tmpDir and is passed to the runtime by absolute path
298
+ // (see buildCommand), so cwd is free to be the project root.
299
+ //
300
+ // Issue #788 — previously only `shell` used the project root; non-shell
301
+ // runtimes (python/js/ts/…) used tmpDir, so repo-relative checks like
302
+ // `pathlib.Path("package.json").exists()` silently failed depending on
303
+ // the chosen language. Unifying cwd removes that surprise.
304
+ // Issue #45 — `cwdOverride` lets per-call sites (Codex MCP handlers) pin
305
+ // cwd without mutating process-wide state.
306
+ const cwd = cwdOverride ?? this.#projectRoot;
307
+ const result = await this.#spawn(cmd, cwd, tmpDir, timeout, background);
308
+
309
+ // Skip tmpDir cleanup if process was backgrounded — it may still need files
310
+ if (!result.backgrounded) {
311
+ cleanupTmpDir(tmpDir);
312
+ }
313
+
314
+ return result;
315
+ } catch (err) {
316
+ cleanupTmpDir(tmpDir);
317
+ throw err;
318
+ }
319
+ }
320
+
321
+ async executeFile(opts: ExecuteFileOptions): Promise<ExecResult> {
322
+ const { path: filePath, language, code, timeout } = opts;
323
+ const absolutePath = resolve(this.#projectRoot, filePath);
324
+ const wrappedCode = this.#wrapWithFileContent(
325
+ absolutePath,
326
+ language,
327
+ code,
328
+ );
329
+ return this.execute({ language, code: wrappedCode, timeout });
330
+ }
331
+
332
+ #writeScript(tmpDir: string, code: string, language: Language): string {
333
+ // Go needs a main package wrapper if not present
334
+ if (language === "go" && !code.includes("package ")) {
335
+ code = `package main\n\nimport "fmt"\n\nfunc main() {\n${code}\n}\n`;
336
+ }
337
+
338
+ // PHP needs opening tag if not present
339
+ if (language === "php" && !code.trimStart().startsWith("<?")) {
340
+ code = `<?php\n${code}`;
341
+ }
342
+
343
+ // Elixir: prepend compiled BEAM paths when inside a Mix project
344
+ if (language === "elixir" && existsSync(join(this.#projectRoot, "mix.exs"))) {
345
+ const escaped = JSON.stringify(join(this.#projectRoot, "_build/dev/lib"));
346
+ code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
347
+ }
348
+
349
+ const fp = join(
350
+ tmpDir,
351
+ buildScriptFilename(
352
+ language,
353
+ process.platform,
354
+ language === "shell" ? this.#runtimes.shell : null,
355
+ ),
356
+ );
357
+ if (language === "shell") {
358
+ const shellPath = this.#runtimes.shell;
359
+ // #782 — on Windows Git Bash, rewrite bare `mvn` → `mvn.cmd` so Maven
360
+ // uses its native Windows launcher (correct path handling) instead of
361
+ // the broken mingw shell branch. No-op on non-Windows.
362
+ const rewritten = rewriteWindowsBuildTools(code, process.platform);
363
+ const shellCode = isWin && isPowerShell(shellPath)
364
+ ? buildPowerShellScriptContent(rewritten)
365
+ : rewritten;
366
+ writeFileSync(
367
+ fp,
368
+ buildShellScriptContent(shellCode, process.env.PATH, process.platform),
369
+ { encoding: "utf-8", mode: 0o700 },
370
+ );
371
+ } else {
372
+ writeFileSync(fp, code, "utf-8");
373
+ }
374
+ return fp;
375
+ }
376
+
377
+ async #compileAndRun(
378
+ srcPath: string,
379
+ cwd: string,
380
+ timeout: number | undefined,
381
+ ): Promise<ExecResult> {
382
+ const binSuffix = isWin ? ".exe" : "";
383
+ const binPath = srcPath.replace(/\.rs$/, "") + binSuffix;
384
+
385
+ // Compile — cap rustc invocation at 60s when caller didn't bound the
386
+ // overall timeout (a hung compile shouldn't run forever even if the
387
+ // caller is fine with a long-running binary afterwards).
388
+ try {
389
+ execFileSync("rustc", [srcPath, "-o", binPath], {
390
+ cwd,
391
+ timeout: timeout === undefined ? 60_000 : Math.min(timeout, 60_000),
392
+ encoding: "utf-8",
393
+ stdio: ["pipe", "pipe", "pipe"],
394
+ });
395
+ } catch (err: unknown) {
396
+ const message = err instanceof Error ? (err as any).stderr || err.message : String(err);
397
+ return {
398
+ stdout: "",
399
+ stderr: `Compilation failed:\n${message}`,
400
+ exitCode: 1,
401
+ timedOut: false,
402
+ };
403
+ }
404
+
405
+ // Run
406
+ return this.#spawn([binPath], cwd, cwd, timeout);
407
+ }
408
+
409
+ async #spawn(
410
+ cmd: string[],
411
+ cwd: string,
412
+ sandboxTmpDir: string,
413
+ timeout: number | undefined,
414
+ background = false,
415
+ ): Promise<ExecResult> {
416
+ return new Promise((res) => {
417
+ // Only .cmd/.bat shims need shell on Windows; real executables don't.
418
+ // Using shell: true globally causes process-tree kill issues with MSYS2/Git Bash.
419
+ // "bun" is included as defense-in-depth: bunCommand() prefers absolute
420
+ // .exe paths now (#506), but if it falls back to the bare "bun" string
421
+ // on Windows that resolution typically goes through a `bun.cmd` shim
422
+ // (npm i -g bun) which CreateProcess can't execute without cmd.exe.
423
+ const needsShell = isWin && ["tsx", "ts-node", "elixir", "bun", "dotnet-script"].includes(cmd[0]);
424
+
425
+ // On Windows with Git Bash, pass the script as `bash -c "source /posix/path"`
426
+ // rather than `bash /path/to/script.sh`. This avoids MSYS2 path mangling
427
+ // while still allowing MSYS_NO_PATHCONV to protect non-ASCII paths in commands.
428
+ let spawnCmd = cmd[0];
429
+ let spawnArgs: string[];
430
+ if (isWin && cmd.length === 2 && cmd[1]) {
431
+ const posixPath = cmd[1].replace(/\\/g, "/");
432
+ spawnArgs = [posixPath];
433
+ } else {
434
+ spawnArgs = isWin
435
+ ? cmd.slice(1).map(a => a.replace(/\\/g, "/"))
436
+ : cmd.slice(1);
437
+ }
438
+
439
+ // Common options shared by both spawn variants below.
440
+ const commonOpts = {
441
+ cwd,
442
+ stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
443
+ env: this.#buildSafeEnv(sandboxTmpDir),
444
+ // On Unix, create a new process group so killTree can kill all children
445
+ detached: !isWin,
446
+ // Hide the spawned-process console window on Windows. Without this,
447
+ // child_process.spawn creates a visible window that intercepts stdout,
448
+ // leaving the MCP response empty and popping a Git Bash terminal over
449
+ // the user's IDE. Issue #384.
450
+ ...buildSpawnOptions(process.platform),
451
+ };
452
+
453
+ // DEP0190 fix: when shell is true (Windows .cmd/.bat shims), pass a
454
+ // single command string instead of cmd + args array. Node.js warns
455
+ // that args are unsafely concatenated when shell:true is combined with
456
+ // the args-array form of spawn(). Colllapsing to a string avoids the
457
+ // warning while preserving the same shell behavior.
458
+ let proc: ReturnType<typeof spawn>;
459
+ if (needsShell) {
460
+ const fullCmd = [spawnCmd, ...spawnArgs]
461
+ .map(a => /\s/.test(a) ? JSON.stringify(a) : a)
462
+ .join(" ");
463
+ proc = spawn(fullCmd, [], { ...commonOpts, shell: true });
464
+ } else {
465
+ proc = spawn(spawnCmd, spawnArgs, { ...commonOpts, shell: false });
466
+ }
467
+
468
+ let timedOut = false;
469
+ let resolved = false;
470
+ // Issue #406 — if the caller didn't pass a timeout we don't fire one.
471
+ // Timeout policy belongs to the MCP host/client (Claude Code, VSCode,
472
+ // JetBrains all enforce their own RPC timeouts); imposing a second
473
+ // policy here turned 30-minute Gradle/Maven/SBT builds into spurious
474
+ // false negatives whenever the caller forgot the explicit value.
475
+ const timer: NodeJS.Timeout | undefined = timeout === undefined ? undefined : setTimeout(() => {
476
+ timedOut = true;
477
+ if (background) {
478
+ // Background mode: detach process, return partial output, keep running
479
+ resolved = true;
480
+ if (proc.pid) this.#backgroundedPids.add(proc.pid);
481
+ proc.unref();
482
+ // Do NOT destroy stdout/stderr — closing the read end of the pipe
483
+ // sends SIGPIPE to the child on its next write, killing it.
484
+ // Instead, replace the data listeners with no-op drains that
485
+ // consume the stream without accumulating buffers. This keeps
486
+ // the pipe open and prevents the child from blocking on a full
487
+ // pipe buffer.
488
+ if (proc.stdout) {
489
+ proc.stdout.removeAllListeners("data");
490
+ proc.stdout.on("data", () => {});
491
+ }
492
+ if (proc.stderr) {
493
+ proc.stderr.removeAllListeners("data");
494
+ proc.stderr.on("data", () => {});
495
+ }
496
+ const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
497
+ const rawStderr = Buffer.concat(stderrChunks).toString("utf-8");
498
+ res({
499
+ stdout: rawStdout,
500
+ stderr: rawStderr,
501
+ exitCode: 0,
502
+ timedOut: true,
503
+ backgrounded: true,
504
+ });
505
+ } else {
506
+ killTree(proc);
507
+ }
508
+ }, timeout);
509
+
510
+ // Stream-level byte cap: kill the process once combined stdout+stderr
511
+ // exceeds hardCapBytes. Without this, a command like `yes` or
512
+ // `cat /dev/urandom | base64` can accumulate gigabytes in memory
513
+ // before the timeout fires.
514
+ const stdoutChunks: Buffer[] = [];
515
+ const stderrChunks: Buffer[] = [];
516
+ let totalBytes = 0;
517
+ let capExceeded = false;
518
+
519
+ proc.stdout!.on("data", (chunk: Buffer) => {
520
+ totalBytes += chunk.length;
521
+ if (totalBytes <= this.#hardCapBytes) {
522
+ stdoutChunks.push(chunk);
523
+ } else if (!capExceeded) {
524
+ capExceeded = true;
525
+ killTree(proc);
526
+ }
527
+ });
528
+
529
+ proc.stderr!.on("data", (chunk: Buffer) => {
530
+ totalBytes += chunk.length;
531
+ if (totalBytes <= this.#hardCapBytes) {
532
+ stderrChunks.push(chunk);
533
+ } else if (!capExceeded) {
534
+ capExceeded = true;
535
+ killTree(proc);
536
+ }
537
+ });
538
+
539
+ proc.on("close", (exitCode) => {
540
+ clearTimeout(timer);
541
+ if (resolved) return; // Already resolved by background timeout
542
+ const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
543
+ let rawStderr = Buffer.concat(stderrChunks).toString("utf-8");
544
+
545
+ if (capExceeded) {
546
+ rawStderr += `\n[output capped at ${(this.#hardCapBytes / 1024 / 1024).toFixed(0)}MB — process killed]`;
547
+ }
548
+
549
+ const stdout = rawStdout;
550
+ const stderr = rawStderr;
551
+
552
+ res({
553
+ stdout,
554
+ stderr,
555
+ exitCode: timedOut ? 1 : (exitCode ?? 1),
556
+ timedOut,
557
+ });
558
+ });
559
+
560
+ proc.on("error", (err) => {
561
+ clearTimeout(timer);
562
+ if (resolved) return; // Already resolved by background timeout
563
+ res({
564
+ stdout: "",
565
+ stderr: err.message,
566
+ exitCode: 1,
567
+ timedOut: false,
568
+ });
569
+ });
570
+ });
571
+ }
572
+
573
+ #buildSafeEnv(tmpDir: string): Record<string, string> {
574
+ const realHome = process.env.HOME ?? process.env.USERPROFILE ?? tmpDir;
575
+
576
+ // Denylist: env vars that corrupt sandbox stdout, inject code, or break
577
+ // language runtimes. Each entry is backed by CVE, MITRE, or live testing.
578
+ // See: https://www.elttam.com/blog/env/, MITRE T1574.006
579
+ const DENIED = new Set([
580
+ // Shell — auto-execute scripts, override builtins
581
+ "BASH_ENV", // sourced by non-interactive bash
582
+ "ENV", // sourced by sh/dash
583
+ "PROMPT_COMMAND", // runs before each prompt
584
+ "PS4", // $(cmd) expansion in xtrace
585
+ "SHELLOPTS", // enables xtrace/verbose, dumps to stdout
586
+ "BASHOPTS", // bash-specific shell options
587
+ "CDPATH", // makes cd print to stdout
588
+ "INPUTRC", // readline key rebinding
589
+ "BASH_XTRACEFD", // redirects debug output to stdout
590
+ // Node.js — require injection, inspector
591
+ "NODE_OPTIONS", // --require, --loader, --inspect
592
+ "NODE_PATH", // module search path injection
593
+ // Python — stdlib override, startup injection
594
+ "PYTHONSTARTUP", // auto-executes in interactive mode
595
+ "PYTHONHOME", // overrides stdlib location (breaks Python)
596
+ "PYTHONWARNINGS", // triggers module import chain → RCE
597
+ "PYTHONBREAKPOINT", // arbitrary callable
598
+ "PYTHONINSPECT", // enters interactive mode after script
599
+ // Ruby — option/module injection
600
+ "RUBYOPT", // injects CLI options (-r loads files)
601
+ "RUBYLIB", // module search path injection
602
+ // Perl — option/module injection
603
+ "PERL5OPT", // injects CLI options (-M runs code)
604
+ "PERL5LIB", // module search path injection
605
+ "PERLLIB", // legacy module search path
606
+ "PERL5DB", // debugger command injection
607
+ // Elixir/Erlang — eval injection
608
+ "ERL_AFLAGS", // prepends erl flags (-eval runs code)
609
+ "ERL_FLAGS", // appends erl flags
610
+ "ELIXIR_ERL_OPTIONS", // Elixir-specific erl flags
611
+ "ERL_LIBS", // beam file loading
612
+ // Go — compiler/linker injection
613
+ "GOFLAGS", // injects go command flags
614
+ "CGO_CFLAGS", // C compiler flag injection
615
+ "CGO_LDFLAGS", // linker flag injection
616
+ // Rust — compiler substitution
617
+ "RUSTC", // arbitrary compiler binary
618
+ "RUSTC_WRAPPER", // compiler wrapper injection
619
+ "RUSTC_WORKSPACE_WRAPPER",
620
+ "CARGO_BUILD_RUSTC",
621
+ "CARGO_BUILD_RUSTC_WRAPPER",
622
+ "RUSTFLAGS", // compiler flag injection
623
+ // PHP — config injection
624
+ "PHPRC", // auto_prepend_file → RCE
625
+ "PHP_INI_SCAN_DIR", // additional .ini loading
626
+ // R — startup script injection
627
+ "R_PROFILE", // site-wide R profile
628
+ "R_PROFILE_USER", // user R profile
629
+ "R_HOME", // R installation override
630
+ // .NET / C# — runtime/startup hooks, additional deps
631
+ "DOTNET_STARTUP_HOOKS", // injects managed assemblies on startup
632
+ "DOTNET_ADDITIONAL_DEPS", // additional .deps.json injection
633
+ "DOTNET_SHARED_STORE", // shared assembly probe path injection
634
+ "DOTNET_ROOT", // arbitrary .NET runtime override
635
+ "DOTNET_ROOT(x86)", // 32-bit override
636
+ "DOTNET_HOST_PATH", // host binary substitution
637
+ // .NET / C# — profiler attach (loads arbitrary DLL into dotnet host)
638
+ // and IPC-based debugger/IL injection. PR #546 follow-up.
639
+ // learn.microsoft.com/en-us/dotnet/core/runtime-config/debugging-profiling
640
+ "CORECLR_PROFILER", // CLSID of profiler to attach
641
+ "CORECLR_PROFILER_PATH", // path to profiler DLL
642
+ "CORECLR_PROFILER_PATH_32", // 32-bit specific profiler DLL
643
+ "CORECLR_PROFILER_PATH_64", // 64-bit specific profiler DLL
644
+ "CORECLR_PROFILER_PATH_ARM32", // ARM32 specific profiler DLL
645
+ "CORECLR_PROFILER_PATH_ARM64", // ARM64 specific profiler DLL
646
+ "CORECLR_ENABLE_PROFILING", // gates profiler load
647
+ "DOTNET_PROFILER_PATH", // cross-platform alias
648
+ "DOTNET_PROFILER_PATH_32",
649
+ "DOTNET_PROFILER_PATH_64",
650
+ "DOTNET_PROFILER_PATH_ARM32",
651
+ "DOTNET_PROFILER_PATH_ARM64",
652
+ "DOTNET_DiagnosticPorts", // peer attach via diagnostic IPC
653
+ "DOTNET_BUNDLE_EXTRACT_BASE_DIR", // single-file extraction hijack
654
+ // Dynamic linker — shared library injection
655
+ "LD_PRELOAD", // loads .so before all others (Linux)
656
+ "DYLD_INSERT_LIBRARIES", // macOS equivalent of LD_PRELOAD
657
+ // OpenSSL — engine loading
658
+ "OPENSSL_CONF", // loads engine modules → .so exec
659
+ "OPENSSL_ENGINES", // engine directory override
660
+ // Compiler — binary substitution
661
+ "CC", // C compiler override
662
+ "CXX", // C++ compiler override
663
+ "AR", // archiver override
664
+ // Git — command injection via hooks/config
665
+ "GIT_TEMPLATE_DIR", // hook injection on git init
666
+ "GIT_CONFIG_GLOBAL", // core.pager/editor runs commands
667
+ "GIT_CONFIG_SYSTEM", // system-level config injection
668
+ "GIT_EXEC_PATH", // substitute git subcommands
669
+ "GIT_SSH", // arbitrary command instead of ssh
670
+ "GIT_SSH_COMMAND", // arbitrary ssh command
671
+ "GIT_ASKPASS", // arbitrary credential command
672
+ ]);
673
+
674
+ // Start with parent env, then strip dangerous vars and apply overrides.
675
+ // The `COMPlus_` prefix sweep covers every COMPlus_* synonym of the
676
+ // DOTNET_* runtime knobs (.NET back-compat alias — case-insensitive).
677
+ // PR #546 follow-up: closes the alias bypass for the explicit denylist
678
+ // entries above.
679
+ const env: Record<string, string> = {};
680
+ for (const [key, val] of Object.entries(process.env)) {
681
+ if (
682
+ val !== undefined &&
683
+ !DENIED.has(key) &&
684
+ !key.startsWith("BASH_FUNC_") &&
685
+ !/^COMPlus_/i.test(key)
686
+ ) {
687
+ env[key] = val;
688
+ }
689
+ }
690
+
691
+ // Sandbox overrides — forced values for correct sandbox behavior
692
+ env["TMPDIR"] = tmpDir;
693
+ env["HOME"] = realHome;
694
+ env["LANG"] = "en_US.UTF-8";
695
+ env["PYTHONDONTWRITEBYTECODE"] = "1";
696
+ env["PYTHONUNBUFFERED"] = "1";
697
+ env["PYTHONUTF8"] = "1";
698
+ env["NO_COLOR"] = "1";
699
+ // Windows uses "Path" (not "PATH") — normalize to "PATH" for consistency
700
+ if (isWin && !env["PATH"] && env["Path"]) {
701
+ env["PATH"] = env["Path"];
702
+ delete env["Path"];
703
+ }
704
+ if (!env["PATH"]) {
705
+ env["PATH"] = isWin ? "" : "/usr/local/bin:/usr/bin:/bin";
706
+ }
707
+
708
+ // Windows-critical PATH fixes.
709
+ if (isWin) {
710
+ // Do not carry global MSYS path-conversion blockers into Git Bash.
711
+ // Native Windows tools launched from bash (notably git.exe) need MSYS
712
+ // to convert /tmp-style arguments to Windows paths so sibling tools see
713
+ // the same filesystem location (#791).
714
+ for (const key of Object.keys(env)) {
715
+ const upper = key.toUpperCase();
716
+ if (upper === "MSYS_NO_PATHCONV" || upper === "MSYS2_ARG_CONV_EXCL") {
717
+ delete env[key];
718
+ }
719
+ }
720
+
721
+ const gitUsrBin = "C:\\Program Files\\Git\\usr\\bin";
722
+ const gitBin = "C:\\Program Files\\Git\\bin";
723
+ if (!env["PATH"].includes(gitUsrBin)) {
724
+ env["PATH"] = `${gitUsrBin};${gitBin};${env["PATH"]}`;
725
+ }
726
+ }
727
+
728
+ // Ensure SSL_CERT_FILE is set so Python/Ruby HTTPS works in sandbox.
729
+ if (!env["SSL_CERT_FILE"]) {
730
+ const certPaths = isWin ? [] : [
731
+ "/etc/ssl/cert.pem", // macOS, some Linux
732
+ "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Alpine
733
+ "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
734
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // Fedora alt
735
+ ];
736
+ for (const p of certPaths) {
737
+ if (existsSync(p)) {
738
+ env["SSL_CERT_FILE"] = p;
739
+ break;
740
+ }
741
+ }
742
+ }
743
+
744
+ return env;
745
+ }
746
+
747
+ #wrapWithFileContent(
748
+ absolutePath: string,
749
+ language: Language,
750
+ code: string,
751
+ ): string {
752
+ const escaped = JSON.stringify(absolutePath);
753
+ switch (language) {
754
+ case "javascript":
755
+ case "typescript":
756
+ return `const FILE_CONTENT_PATH = ${escaped};\nconst file_path = FILE_CONTENT_PATH;\nconst FILE_CONTENT = require("fs").readFileSync(FILE_CONTENT_PATH, "utf-8");\n${code}`;
757
+ case "python":
758
+ return `FILE_CONTENT_PATH = ${escaped}\nfile_path = FILE_CONTENT_PATH\nwith open(FILE_CONTENT_PATH, "r", encoding="utf-8") as _f:\n FILE_CONTENT = _f.read()\n${code}`;
759
+ case "shell": {
760
+ // Single-quote the path to prevent $, backtick, and ! expansion
761
+ const sq = "'" + absolutePath.replace(/'/g, "'\\''") + "'";
762
+ return `FILE_CONTENT_PATH=${sq}\nfile_path=${sq}\nFILE_CONTENT=$(cat ${sq})\n${code}`;
763
+ }
764
+ case "ruby":
765
+ return `FILE_CONTENT_PATH = ${escaped}\nfile_path = FILE_CONTENT_PATH\nFILE_CONTENT = File.read(FILE_CONTENT_PATH, encoding: "utf-8")\n${code}`;
766
+ case "go":
767
+ return `package main\n\nimport (\n\t"fmt"\n\t"os"\n)\n\nvar FILE_CONTENT_PATH = ${escaped}\nvar file_path = FILE_CONTENT_PATH\n\nfunc main() {\n\tb, _ := os.ReadFile(FILE_CONTENT_PATH)\n\tFILE_CONTENT := string(b)\n\t_ = FILE_CONTENT\n\t_ = fmt.Sprint()\n${code}\n}\n`;
768
+ case "rust":
769
+ return `#![allow(unused_variables)]\nuse std::fs;\n\nfn main() {\n let file_content_path = ${escaped};\n let file_path = file_content_path;\n let file_content = fs::read_to_string(file_content_path).unwrap();\n${code}\n}\n`;
770
+ case "php":
771
+ return `<?php\n$FILE_CONTENT_PATH = ${escaped};\n$file_path = $FILE_CONTENT_PATH;\n$FILE_CONTENT = file_get_contents($FILE_CONTENT_PATH);\n${code}`;
772
+ case "perl":
773
+ return `my $FILE_CONTENT_PATH = ${escaped};\nmy $file_path = $FILE_CONTENT_PATH;\nopen(my $fh, '<:encoding(UTF-8)', $FILE_CONTENT_PATH) or die "Cannot open: $!";\nmy $FILE_CONTENT = do { local $/; <$fh> };\nclose($fh);\n${code}`;
774
+ case "r":
775
+ return `FILE_CONTENT_PATH <- ${escaped}\nfile_path <- FILE_CONTENT_PATH\nFILE_CONTENT <- readLines(FILE_CONTENT_PATH, warn=FALSE, encoding="UTF-8")\nFILE_CONTENT <- paste(FILE_CONTENT, collapse="\\n")\n${code}`;
776
+ case "elixir":
777
+ return `file_content_path = ${escaped}\nfile_path = file_content_path\nfile_content = File.read!(file_content_path)\n${code}`;
778
+ case "csharp":
779
+ // .csx forbids `using` directives after any other top-level statement
780
+ // (CS1529). User code inside executeFile must use fully-qualified type
781
+ // names (e.g. `System.Text.Json.JsonDocument`) instead of `using`.
782
+ return `var FILE_CONTENT_PATH = ${escaped};\nvar file_path = FILE_CONTENT_PATH;\nvar FILE_CONTENT = System.IO.File.ReadAllText(FILE_CONTENT_PATH);\n${code}`;
783
+ }
784
+ }
785
+ }