Files
codegraph/__tests__/child-process-windows-hide.test.ts
Colby Mchenry ad60dccd04 test(windows): the windowsHide guard fails closed on spawns it can't trace (#2312) (#2315)
the watcher's `git config --get core.excludesFile` call. That report is
against 1.6.1; the call (and the `ls-files --exclude-standard` one next to
it) already runs with windowsHide on main since #2280, which has not been
released yet. An audit of every child_process call in src/ found nothing
else missing it: 31 call sites, 29 hidden, 2 documented exceptions
(macOS-only vm_stat; the detached browser opener).

What #2280's AST guard could not see is how the next regression would slip
in. It only followed static named/namespace imports and passed silently on
anything else, so each of these shipped green with no windowsHide:

- child_process loaded by a default import, `import = require`,
  `require()`, `import()`, or a re-export
- a spawner aliased or passed as a value (`const run = execFileSync`,
  `promisify(execFile)`, `const { spawn } = cp`)
- options in a `let`, or a later spread that may set windowsHide back
- an options parameter of an exported wrapper (callers in other files),
  a wrapper used as a callback, or a wrapper call that omits the options

The guard now fails closed on all of them, and a second describe pins each
shape (plus the compliant ones it must keep passing) so the guard itself
can't go blind again.

Also credit #2312 on the existing changelog entry and drop the duplicate
copy of that entry #2280 left under [Unreleased].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:55:49 +00:00

391 lines
18 KiB
TypeScript

/**
* Static source guard (all platforms): every child process spawned from `src/`
* sets `windowsHide: true`.
*
* The MCP daemon is spawned detached and has no console of its own. On
* Windows, a console program (git, …) it starts without windowsHide gets a
* brand-new visible console, so a terminal window flashes on screen and closes
* once per call: on daemon start and again on every auto-sync (#485, #928,
* #1092, #2094, #2096, #2312). With Windows Terminal set as the default
* terminal, each flash is a full Windows Terminal window.
*
* windowsHide is Windows-only behavior the POSIX test runs can't observe, so it
* is asserted on the TypeScript AST. Options passed through a variable or a
* function parameter are traced back to the object literal that defines them
* (for a parameter: through every call site of that function in the file).
*
* The guard fails closed: a spawn it can't trace is an offender, not a pass.
* That covers child_process reached any way other than a static import
* (`require`, `import()`, a re-export), a spawner used as a value instead of
* called (`const run = execFileSync`, `promisify(execFile)`, `const { spawn } =
* cp`), options in a `let`, and an options parameter of an exported function
* (its callers in other files are out of sight). The second `describe` pins
* each of those shapes so the guard itself can't silently go blind.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import ts from 'typescript';
const SRC_DIR = path.join(__dirname, '..', 'src');
/**
* child_process functions that start a process and accept `windowsHide`, by
* argument shape: `file` is `(file, args?, options?)`, `command` is
* `(command, options?)`.
*/
const SPAWNERS: Record<string, 'file' | 'command'> = {
exec: 'command',
execSync: 'command',
execFile: 'file',
execFileSync: 'file',
spawn: 'file',
spawnSync: 'file',
fork: 'file',
};
const CHILD_PROCESS_MODULES = new Set(['child_process', 'node:child_process']);
/**
* Spawns that intentionally run without windowsHide, keyed by
* `<path relative to src>:<spawner>(<command>)`, valued by the reason.
*/
const ALLOWED: Record<string, string> = {
"resolution/memory-budget.ts:execFileSync('/usr/bin/vm_stat')": 'macOS only: guarded by process.platform === darwin',
'ui-server/open-browser.ts:spawn(open.command)':
'detached: true maps to DETACHED_PROCESS on Windows, so `cmd /c start` gets no console to show',
};
function listSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listSourceFiles(p));
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(p);
}
return out;
}
function isChildProcessModule(node: ts.Node | undefined): boolean {
return node !== undefined && ts.isStringLiteralLike(node) && CHILD_PROCESS_MODULES.has(node.text);
}
/** Local names bound to child_process exports in one file. */
interface ChildProcessBindings {
/** Local name → imported child_process export (`import { spawn as s }`). */
named: Map<string, string>;
/**
* Names bound to the module object: `import * as cp`, `import cp` (with
* esModuleInterop) and `import cp = require(…)`.
*/
namespaces: Set<string>;
}
function childProcessBindings(sf: ts.SourceFile): ChildProcessBindings {
const named = new Map<string, string>();
const namespaces = new Set<string>();
for (const stmt of sf.statements) {
if (ts.isImportEqualsDeclaration(stmt)) {
const ref = stmt.moduleReference;
if (ts.isExternalModuleReference(ref) && isChildProcessModule(ref.expression)) namespaces.add(stmt.name.text);
continue;
}
if (!ts.isImportDeclaration(stmt) || !isChildProcessModule(stmt.moduleSpecifier)) continue;
const clause = stmt.importClause;
if (!clause) continue; // bare `import 'child_process'` binds nothing
if (clause.name) namespaces.add(clause.name.text);
const bindings = clause.namedBindings;
if (!bindings) continue;
if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
else for (const el of bindings.elements) named.set(el.name.text, (el.propertyName ?? el.name).text);
}
return { named, namespaces };
}
/** The child_process function a call invokes, or null when it isn't one. */
function spawnerOf(call: ts.CallExpression, b: ChildProcessBindings): string | null {
const callee = call.expression;
const name = ts.isIdentifier(callee)
? b.named.get(callee.text)
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && b.namespaces.has(callee.expression.text)
? callee.name.text
: undefined;
return name !== undefined && Object.hasOwn(SPAWNERS, name) ? name : null;
}
/** The options argument of a spawner call, or undefined when none is passed. */
function optionsArg(call: ts.CallExpression, spawner: string): ts.Expression | undefined {
const [, second, third] = call.arguments;
if (SPAWNERS[spawner] === 'command') return second;
// (file, options) is allowed when the args array is omitted.
if (second && ts.isObjectLiteralExpression(second)) return second;
return third;
}
function unwrap(expr: ts.Expression): ts.Expression {
let e = expr;
while (ts.isAsExpression(e) || ts.isParenthesizedExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
return e;
}
function isCallee(node: ts.Node): boolean {
return ts.isCallExpression(node.parent) && node.parent.expression === node;
}
/**
* True when `id` reads the binding it names as a value. False for a type
* position (`typeof execFileSync`), and for an identifier that only names a
* declaration or a member: `x.exec`, `{ exec: … }`, `const spawn = …`, the
* import specifier itself. A re-export (`export { spawn }`) and a shorthand
* property (`{ spawn }`) DO read the value.
*/
function isValueReference(id: ts.Identifier): boolean {
for (let n: ts.Node | undefined = id.parent; n; n = n.parent) if (ts.isTypeNode(n)) return false;
const p = id.parent as ts.Node & { name?: ts.Node; propertyName?: ts.Node };
if (ts.isShorthandPropertyAssignment(p) || ts.isExportSpecifier(p)) return true;
if (p.name === id || p.propertyName === id) return false;
if (ts.isQualifiedName(p) && p.right === id) return false;
return true;
}
/** Variable initializer or function parameter that `name` refers to at `from`. */
function resolveBinding(name: string, from: ts.Node): ts.VariableDeclaration | ts.ParameterDeclaration | null {
for (let scope: ts.Node | undefined = from.parent; scope; scope = scope.parent) {
if (ts.isFunctionLike(scope)) {
const param = scope.parameters.find((p) => ts.isIdentifier(p.name) && p.name.text === name);
if (param) return param;
}
if (ts.isBlock(scope) || ts.isSourceFile(scope) || ts.isModuleBlock(scope)) {
for (const stmt of scope.statements) {
if (!ts.isVariableStatement(stmt)) continue;
const decl = stmt.declarationList.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === name);
if (decl) return decl;
}
}
}
return null;
}
/**
* The argument every call of the function that declares `param` passes for it
* (undefined where a call omits it), found by name in the same file. Null when
* the callers can't all be seen: the function is unnamed, exported (callers in
* other files), or used as a value (a callback receives whatever its caller
* passes).
*/
function callSitesOf(param: ts.ParameterDeclaration, sf: ts.SourceFile): (ts.Expression | undefined)[] | null {
const fn = param.parent;
if (!ts.isFunctionDeclaration(fn) || !fn.name) return null;
if (fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return null;
const fnName = fn.name.text;
const index = fn.parameters.indexOf(param);
const args: (ts.Expression | undefined)[] = [];
let usedAsValue = false;
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && node.text === fnName && node !== fn.name && isValueReference(node)) {
if (!isCallee(node)) usedAsValue = true;
else {
const call = node.parent as ts.CallExpression;
// A spread before the parameter's slot hides which value lands in it.
if (call.arguments.slice(0, index + 1).some(ts.isSpreadElement)) usedAsValue = true;
else args.push(call.arguments[index]);
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return usedAsValue || args.length === 0 ? null : args;
}
/**
* What options `expr` evaluates to do with windowsHide: provably set to true
* (`hidden`), provably not set at all (`absent`, e.g. no options passed, or a
* spread of `{ cwd }`), or anything else (`unhidden`: false, a non-literal, or
* unknowable). `absent` matters for spreads: `{ windowsHide: true, ...extra }`
* stays hidden only when `extra` provably doesn't set it back.
*/
type HideState = 'hidden' | 'absent' | 'unhidden';
function hideState(expr: ts.Expression | undefined, sf: ts.SourceFile, seen = new Set<ts.Node>()): HideState {
if (!expr) return 'absent';
const e = unwrap(expr);
if (seen.has(e)) return 'unhidden'; // a cycle proves nothing
// Path-local: two call sites passing the same options object both resolve.
const trail = new Set(seen).add(e);
if (ts.isObjectLiteralExpression(e)) {
let state: HideState = 'absent';
for (const prop of e.properties) {
if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === 'windowsHide') {
state = unwrap(prop.initializer).kind === ts.SyntaxKind.TrueKeyword ? 'hidden' : 'unhidden';
} else if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'windowsHide') {
state = 'unhidden'; // not provable statically
} else if (ts.isSpreadAssignment(prop)) {
const spread = hideState(prop.expression, sf, trail);
if (spread !== 'absent') state = spread;
}
}
return state; // last write wins, matching object-literal semantics
}
if (ts.isIdentifier(e)) {
const binding = resolveBinding(e.text, e);
if (!binding) return 'unhidden';
if (ts.isVariableDeclaration(binding)) {
// A `let` or `var` can be reassigned after its initializer.
const list = binding.parent;
if (!ts.isVariableDeclarationList(list) || !(list.flags & ts.NodeFlags.Const)) return 'unhidden';
return binding.initializer ? hideState(binding.initializer, sf, trail) : 'unhidden';
}
const sites = callSitesOf(binding, sf);
if (sites === null) return 'unhidden';
const states = new Set(sites.map((arg) => hideState(arg, sf, trail)));
return states.size === 1 ? [...states][0] : 'unhidden';
}
return 'unhidden';
}
/** True when `expr` provably evaluates to options with `windowsHide: true`. */
function hidesWindow(expr: ts.Expression | undefined, sf: ts.SourceFile): boolean {
return hideState(expr, sf) === 'hidden';
}
function commandLabel(call: ts.CallExpression, sf: ts.SourceFile): string {
const first = call.arguments[0];
if (!first) return '';
if (ts.isStringLiteralLike(first)) return `'${first.text}'`;
return first.getText(sf);
}
/** Every spawn in one file, and each one that isn't provably hidden. */
function scanSource(rel: string, text: string): { seen: number; offenders: string[] } {
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const bindings = childProcessBindings(sf);
const offenders: string[] = [];
let seen = 0;
const flag = (node: ts.Node, what: string): void => {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
offenders.push(`${rel}:${line + 1} ${what}`);
};
const visit = (node: ts.Node): void => {
if (ts.isExportDeclaration(node) && isChildProcessModule(node.moduleSpecifier)) {
flag(node, 're-exports child_process: spawns through the re-export are out of sight');
}
if (ts.isCallExpression(node)) {
const callee = node.expression;
const loads =
(ts.isIdentifier(callee) && callee.text === 'require') || callee.kind === ts.SyntaxKind.ImportKeyword;
if (loads && isChildProcessModule(node.arguments[0])) {
flag(node, `${callee.getText(sf)}(child_process): import it statically so its spawns can be checked`);
}
const spawner = spawnerOf(node, bindings);
if (spawner) {
seen++;
const key = `${rel}:${spawner}(${commandLabel(node, sf)})`;
if (!Object.hasOwn(ALLOWED, key) && !hidesWindow(optionsArg(node, spawner), sf)) flag(node, key);
}
}
const bound = ts.isIdentifier(node) && (bindings.named.has(node.text) || bindings.namespaces.has(node.text));
if (bound && isValueReference(node)) {
const imported = bindings.named.get(node.text);
if (imported !== undefined && Object.hasOwn(SPAWNERS, imported) && !isCallee(node)) {
flag(node, `${node.text} used as a value: call it directly so its options can be checked`);
} else if (bindings.namespaces.has(node.text)) {
const member = node.parent;
const direct =
ts.isPropertyAccessExpression(member) &&
member.expression === node &&
(!Object.hasOwn(SPAWNERS, member.name.text) || isCallee(member));
if (!direct) flag(node, `${node.getText(sf)} used as a value: call its spawners directly so their options can be checked`);
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return { seen, offenders };
}
describe('child processes set windowsHide (#1092, #2094, #2312)', () => {
it('every child_process spawn under src/ sets windowsHide: true', () => {
const offenders: string[] = [];
let seen = 0;
for (const file of listSourceFiles(SRC_DIR)) {
const rel = path.relative(SRC_DIR, file).split(path.sep).join('/');
const result = scanSource(rel, fs.readFileSync(file, 'utf8'));
seen += result.seen;
offenders.push(...result.offenders);
}
expect(seen).toBeGreaterThan(0); // guard against a false pass if the imports move
expect(offenders, `spawned without windowsHide:\n${offenders.join('\n')}`).toEqual([]);
});
});
describe('the windowsHide guard fails closed', () => {
const scan = (text: string) => scanSource('fixture.ts', text);
it.each([
[
'the core.excludesFile read the watcher repeats (#2312)',
`import { execFileSync } from 'child_process';
execFileSync('git', ['-C', root, 'config', '--get', 'core.excludesFile'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });`,
],
['windowsHide: false', `import { spawn } from 'child_process'; spawn('git', [], { windowsHide: false });`],
['a later spread overriding it', `import { spawn } from 'child_process'; spawn('git', [], { windowsHide: true, ...{ windowsHide: false } });`],
['a later spread that might override it', `import { spawn } from 'child_process'; spawn('git', [], { windowsHide: true, ...extra() });`],
['a default import', `import cp from 'child_process'; cp.execFileSync('git', ['status'], { encoding: 'utf8' });`],
['an import-equals', `import cp = require('child_process'); cp.spawnSync('git', ['status']);`],
['a require()', `const { execFileSync } = require('child_process'); execFileSync('git', ['status'], { windowsHide: true });`],
['a dynamic import()', `async function f() { const cp = await import('node:child_process'); }`],
['a re-export', `export { spawn } from 'child_process';`],
['a re-export of the import', `import { spawn } from 'child_process'; export { spawn };`],
['an alias', `import { execFileSync } from 'child_process'; const run = execFileSync; run('git', ['status']);`],
['promisify', `import { execFile } from 'child_process'; import { promisify } from 'util'; const run = promisify(execFile);`],
['a shorthand property', `import { spawn } from 'child_process'; const deps = { spawn };`],
['a destructured namespace', `import * as cp from 'child_process'; const { spawn } = cp; spawn('git', []);`],
['a namespace spawner as a value', `import * as cp from 'child_process'; [].forEach(cp.spawn);`],
[
'options in a let',
`import { execFileSync } from 'child_process';
let opts = { windowsHide: true }; opts = {}; execFileSync('git', [], opts);`,
],
[
'an exported wrapper (its callers elsewhere are out of sight)',
`import { execFileSync } from 'child_process';
export function git(args: string[], opts: object) { return execFileSync('git', args, opts); }
git(['status'], { windowsHide: true });`,
],
[
'a wrapper call that omits the options',
`import { execFileSync } from 'child_process';
function git(args: string[], opts?: object) { return execFileSync('git', args, opts); }
git(['status'], { windowsHide: true }); git(['log']);`,
],
[
'a wrapper passed as a callback',
`import { execFileSync } from 'child_process';
function git(opts: object) { return execFileSync('git', [], opts); }
git({ windowsHide: true }); [{}].map(git);`,
],
])('flags %s', (_label, text) => {
expect(scan(text).offenders).not.toEqual([]);
});
it.each([
['a literal', `import { execFileSync } from 'child_process'; execFileSync('git', ['status'], { windowsHide: true });`],
['(file, options)', `import { execFileSync } from 'child_process'; execFileSync('git', { windowsHide: true });`],
['a command spawner', `import { exec } from 'child_process'; exec('git status', { windowsHide: true }); /a/.exec('a');`],
['a const', `import { spawn } from 'child_process'; const o = { windowsHide: true }; spawn('git', [], o);`],
['a spread const', `import { spawn } from 'child_process'; const base = { windowsHide: true }; spawn('git', [], { ...base, cwd: '.' });`],
['a later spread that leaves it alone', `import { spawn } from 'child_process'; const extra = { cwd: '.' }; spawn('git', [], { windowsHide: true, ...extra });`],
['a namespace', `import * as cp from 'node:child_process'; let c: cp.ChildProcess | undefined; cp.spawn('git', [], { windowsHide: true });`],
[
'a wrapper whose every call passes the same hidden options',
`import { execFileSync } from 'child_process';
function git(opts: Parameters<typeof execFileSync>[2]) { return execFileSync('git', [], opts); }
const gitOpts = { cwd: '.', windowsHide: true };
git(gitOpts); git(gitOpts);`,
],
['types only', `import type { ChildProcess } from 'child_process'; let c: ChildProcess | undefined;`],
])('passes %s', (_label, text) => {
expect(scan(text).offenders).toEqual([]);
});
});