From f95eed3e0ca7fc503359432ce66ea03935af2962 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:24:46 +0000 Subject: [PATCH 1/6] Initial plan From 48f67c6286bc53c28469ac25cfaa2b75b810e443 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:38:12 +0000 Subject: [PATCH 2/6] Add child_process interpolated-command lint rule Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- eslint-factory/README.md | 27 ++++ eslint-factory/src/index.ts | 2 + ...child-process-interpolated-command.test.ts | 59 +++++++ .../no-child-process-interpolated-command.ts | 147 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 eslint-factory/src/rules/no-child-process-interpolated-command.test.ts create mode 100644 eslint-factory/src/rules/no-child-process-interpolated-command.ts diff --git a/eslint-factory/README.md b/eslint-factory/README.md index 6ae7017e0d9..b119d4311be 100644 --- a/eslint-factory/README.md +++ b/eslint-factory/README.md @@ -20,6 +20,7 @@ This project hosts custom ESLint linters for `/actions/setup/js`. |---|---| | [`no-core-exportvariable-non-string`](#no-core-exportvariable-non-string) | Require explicit string values for `core.exportVariable` calls | | [`no-core-setoutput-non-string`](#no-core-setoutput-non-string) | Require explicit string values for `core.setOutput` calls | +| [`no-child-process-interpolated-command`](#no-child-process-interpolated-command) | Disallow interpolated command strings in shell-evaluated `child_process` calls | | [`no-github-request-interpolated-route`](#no-github-request-interpolated-route) | Disallow interpolated route arguments in Octokit `.request()` calls | | [`no-json-stringify-error`](#no-json-stringify-error) | Disallow `JSON.stringify()` on caught error variables | | [`no-throw-plain-object`](#no-throw-plain-object) | Disallow throwing plain object literals | @@ -432,6 +433,32 @@ Prefer `@actions/core` logging methods (`core.info`, `core.debug`) over `console `console.error` and `console.warn` write to **`process.stderr`**, while `core.error` and `core.warning` emit GitHub Actions workflow commands to **`process.stdout`**. For processes that own stdout as a data/protocol channel — such as stdio MCP servers and transports — replacing stderr logging with stdout logging would corrupt the JSON-RPC stream. Because the stream change is not behavior-preserving, the rule never reports `console.error` or `console.warn` and offers no suggestion to replace them. +### `no-child-process-interpolated-command` + +Disallow interpolated template literals and dynamic string concatenation as command arguments to shell-evaluated `child_process` calls. + +Why: command strings evaluated by a shell (`exec`, `execSync`, `spawn` / `spawnSync` with `shell: true`, and `execFile` / `execFileSync` with `shell: true`) can become shell-injection vectors when command content is assembled dynamically. + +**Detected forms (when bound to `child_process` / `node:child_process`):** +- `const { execSync } = require("child_process"); execSync(\`git checkout ${branch}\`)` +- `const cp = require("child_process"); cp.exec("git checkout " + branch)` +- `const run = cp.execSync; run("git checkout " + branch)` — member alias call. +- `spawn(\`git checkout ${branch}\`, { shell: true })` — shell-enabled spawn. +- `execFileSync("git " + branch, ["status"], { shell: true })` — shell-enabled execFileSync. +- ESM imports are recognized (`import { execSync } from "node:child_process"`). + +**Not flagged:** +- Fully static command strings (`"git status"`, `` `git status` ``, and fully static `+` concatenations). +- `spawn(cmd, [args])` / `spawnSync(cmd, [args])` without `shell: true`. +- `execFile` / `execFileSync` without `shell: true`. + +**Out of scope:** github-script's injected `exec.exec(...)` / `exec.getExecOutput(...)`. Those are covered by `no-exec-interpolated-command`, which targets `@actions/exec` argument-splitting correctness rather than shell injection. + +**Safer alternatives:** +- Use a static executable and pass arguments as an array (`execFileSync("git", ["checkout", branch])`). +- Avoid `shell: true` unless strictly required. + + ### `require-execsync-try-catch` Require `execSync` calls sourced from `child_process` to be wrapped in `try/catch`. diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index 8fb6c35ac74..f788b205aa8 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -20,6 +20,7 @@ import { requireNewUrlTryCatchRule } from "./rules/require-new-url-try-catch"; import { preferCoreLoggingRule } from "./rules/prefer-core-logging"; import { noCoreErrorThenProcessExitRule } from "./rules/no-core-error-then-process-exit"; import { noCoreErrorThenProcessExitCodeRule } from "./rules/no-core-error-then-process-exitcode"; +import { noChildProcessInterpolatedCommandRule } from "./rules/no-child-process-interpolated-command"; import { noExecInterpolatedCommandRule } from "./rules/no-exec-interpolated-command"; import { requireExecSyncTryCatchRule } from "./rules/require-execsync-try-catch"; import { requireExecFileSyncTryCatchRule } from "./rules/require-execfilesync-try-catch"; @@ -55,6 +56,7 @@ const plugin = { "prefer-core-logging": preferCoreLoggingRule, "no-core-error-then-process-exit": noCoreErrorThenProcessExitRule, "no-core-error-then-process-exitcode": noCoreErrorThenProcessExitCodeRule, + "no-child-process-interpolated-command": noChildProcessInterpolatedCommandRule, "no-exec-interpolated-command": noExecInterpolatedCommandRule, "require-execsync-try-catch": requireExecSyncTryCatchRule, "require-execfilesync-try-catch": requireExecFileSyncTryCatchRule, diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts new file mode 100644 index 00000000000..550437462e7 --- /dev/null +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts @@ -0,0 +1,59 @@ +import { RuleTester } from "eslint"; +import { describe, it } from "vitest"; +import { noChildProcessInterpolatedCommandRule } from "./no-child-process-interpolated-command"; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + }, +}); + +describe("no-child-process-interpolated-command", () => { + it("flags dynamic child_process command strings for shell-evaluated methods", () => { + ruleTester.run("no-child-process-interpolated-command", noChildProcessInterpolatedCommandRule, { + valid: [ + { code: `const { execSync } = require("child_process"); execSync("git status");` }, + { code: `const cp = require("child_process"); cp.execSync(\`git status\`);` }, + { code: `const cp = require("child_process"); cp.spawn("git", ["status"]);` }, + { code: `const { spawn } = require("child_process"); spawn(\`git \${branch}\`, ["status"]);` }, + { code: `const { execFileSync } = require("child_process"); execFileSync("git", ["status"], { shell: false });` }, + { code: `exec.exec(\`git checkout \${branch}\`, []);` }, + { + code: `import { exec } from "node:child_process"; exec("git status");`, + languageOptions: { sourceType: "module" }, + }, + ], + invalid: [ + { + code: `const { execSync } = require("child_process"); execSync(\`git checkout \${branch}\`);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "execSync" } }], + }, + { + code: `const cp = require("child_process"); const run = cp.execSync; run("git checkout " + branch);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "execSync" } }], + }, + { + code: `const cp = require("node:child_process"); cp.exec(\`git checkout \${branch}\`);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, + { + code: `const { spawn } = require("child_process"); spawn(\`git checkout \${branch}\`, { shell: true });`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "spawn" } }], + }, + { + code: `const { spawnSync } = require("child_process"); spawnSync("git checkout " + branch, ["--"], { shell: true });`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "spawnSync" } }], + }, + { + code: `const { execFileSync } = require("child_process"); execFileSync(\`git \${branch}\`, ["status"], { shell: true });`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "execFileSync" } }], + }, + { + code: `const { execFile } = require("child_process"); execFile("git " + branch, { shell: true });`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "execFile" } }], + }, + ], + }); + }); +}); diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.ts new file mode 100644 index 00000000000..812c213e60c --- /dev/null +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.ts @@ -0,0 +1,147 @@ +import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; +import { isChildProcessImportBinding, isChildProcessObjectBinding, isRequireChildProcess } from "./try-catch-rule-utils"; + +const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); + +type SourceCodeScope = ReturnType; +type ChildProcessMethod = "exec" | "execSync" | "spawn" | "spawnSync" | "execFile" | "execFileSync"; +const SHELL_CONDITIONAL_METHODS = new Set(["spawn", "spawnSync", "execFile", "execFileSync"]); + +function isStaticExpression(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Literal) return true; + if (node.type === AST_NODE_TYPES.TemplateLiteral) return node.expressions.length === 0; + if (node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+") { + return isStaticExpression(node.left) && isStaticExpression(node.right); + } + return false; +} + +function isDynamicStringConcatenation(node: TSESTree.Expression): boolean { + return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+" && !isStaticExpression(node); +} + +function getDynamicCommandKind(node: TSESTree.Expression): string | null { + if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length > 0) return "interpolated template literal"; + if (isDynamicStringConcatenation(node)) return "dynamic string concatenation"; + return null; +} + +function isShellTrueOption(optionsArg: TSESTree.CallExpressionArgument | undefined): boolean { + if (!optionsArg || optionsArg.type === AST_NODE_TYPES.SpreadElement || optionsArg.type !== AST_NODE_TYPES.ObjectExpression) return false; + + for (const prop of optionsArg.properties) { + if (prop.type !== AST_NODE_TYPES.Property || prop.computed) continue; + + const keyName = prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name : prop.key.type === AST_NODE_TYPES.Literal ? prop.key.value : null; + if (keyName !== "shell") continue; + + return prop.value.type === AST_NODE_TYPES.Literal && prop.value.value === true; + } + + return false; +} + +function requiresShellTrue(method: ChildProcessMethod): boolean { + return SHELL_CONDITIONAL_METHODS.has(method); +} + +function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod): boolean { + if (!requiresShellTrue(method)) return true; + return isShellTrueOption(node.arguments[1]) || isShellTrueOption(node.arguments[2]); +} + +function isChildProcessMethodBinding(method: ChildProcessMethod, identifierName: string, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean { + let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode); + while (scope) { + const variable = scope.set.get(identifierName); + if (variable && variable.defs.length > 0) { + for (const def of variable.defs) { + if (isChildProcessImportBinding(def) && def.node.type === AST_NODE_TYPES.ImportSpecifier) { + const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier ? def.node.imported.name : null; + if (importedName === method) return true; + } + + if (def.type !== "Variable") continue; + + const declarator = def.node as TSESTree.VariableDeclarator; + if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && isRequireChildProcess(declarator.init)) { + for (const prop of declarator.id.properties) { + if (prop.type !== AST_NODE_TYPES.Property || prop.key.type !== AST_NODE_TYPES.Identifier || prop.key.name !== method) continue; + const boundName = prop.value.type === AST_NODE_TYPES.Identifier ? prop.value.name : null; + if (boundName === identifierName) return true; + } + } + + if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init?.type === AST_NODE_TYPES.MemberExpression) { + const init = declarator.init; + if (!init.computed && init.object.type === AST_NODE_TYPES.Identifier && isChildProcessObjectBinding(init.object.name, init.object, sourceCode) && init.property.type === AST_NODE_TYPES.Identifier && init.property.name === method) { + return true; + } + } + } + return false; + } + scope = scope.upper; + } + return false; +} + +function resolveChildProcessMethod(node: TSESTree.CallExpression, sourceCode: TSESLint.SourceCode): ChildProcessMethod | null { + const callee = node.callee; + if (callee.type === AST_NODE_TYPES.Identifier) { + const methods: ChildProcessMethod[] = ["exec", "execSync", "spawn", "spawnSync", "execFile", "execFileSync"]; + for (const method of methods) { + if (isChildProcessMethodBinding(method, callee.name, callee, sourceCode)) return method; + } + return null; + } + + if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return null; + if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.property.type !== AST_NODE_TYPES.Identifier) return null; + if (!isChildProcessObjectBinding(callee.object.name, callee.object, sourceCode)) return null; + + const method = callee.property.name; + return method === "exec" || method === "execSync" || method === "spawn" || method === "spawnSync" || method === "execFile" || method === "execFileSync" ? method : null; +} + +export const noChildProcessInterpolatedCommandRule = createRule({ + name: "no-child-process-interpolated-command", + meta: { + type: "problem", + docs: { + description: + "Disallow interpolated template literals or dynamic string concatenation as child_process command arguments for shell-evaluated execution paths. " + + "Dynamic command strings can become shell-injection vectors. Prefer static command names with argument arrays and avoid shell: true.", + }, + schema: [], + messages: { + interpolatedCommand: + "Avoid passing a {{kind}} as the command to child_process.{{method}} — shell-evaluated command strings can enable shell injection. " + + "Prefer a static executable plus an argument array (for example, execFileSync(cmd, [arg1, arg2])) and avoid shell: true when possible.", + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + CallExpression(node) { + const method = resolveChildProcessMethod(node, sourceCode); + if (!method) return; + if (!hasShellTrueOptions(node, method)) return; + + const firstArg = node.arguments[0]; + if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; + + const kind = getDynamicCommandKind(firstArg); + if (!kind) return; + + context.report({ + node: firstArg, + messageId: "interpolatedCommand", + data: { kind, method }, + }); + }, + }; + }, +}); From c83b3510975f0345756f6e98c3e6baae35ad452a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:31:36 +0000 Subject: [PATCH 3/6] Fix child_process rule activation and shell option resolution gaps Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- eslint-factory/eslint.config.cjs | 1 + ...child-process-interpolated-command.test.ts | 20 +++++++++ .../no-child-process-interpolated-command.ts | 45 +++++++++++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index 13d5de678bf..1a0acbd9d3b 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -34,6 +34,7 @@ module.exports = [ "gh-aw-custom/prefer-core-logging": "warn", "gh-aw-custom/no-core-error-then-process-exit": "warn", "gh-aw-custom/no-core-error-then-process-exitcode": "warn", + "gh-aw-custom/no-child-process-interpolated-command": "warn", "gh-aw-custom/no-exec-interpolated-command": "warn", "gh-aw-custom/require-execsync-try-catch": "warn", "gh-aw-custom/require-execfilesync-try-catch": "warn", diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts index 550437462e7..6fc44573b0d 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts @@ -41,6 +41,18 @@ describe("no-child-process-interpolated-command", () => { code: `const { spawn } = require("child_process"); spawn(\`git checkout \${branch}\`, { shell: true });`, errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "spawn" } }], }, + { + code: `const { spawn } = require("child_process"); const opts = { shell: true }; spawn(\`git checkout \${branch}\`, opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "spawn" } }], + }, + { + code: `const { spawn } = require("child_process"); spawn(\`git checkout \${branch}\`, { shell: "/bin/bash" });`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "spawn" } }], + }, + { + code: `const { spawn } = require("child_process"); const opts = [{ shell: true }]; spawn("git checkout " + branch, ...opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "spawn" } }], + }, { code: `const { spawnSync } = require("child_process"); spawnSync("git checkout " + branch, ["--"], { shell: true });`, errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "spawnSync" } }], @@ -53,6 +65,14 @@ describe("no-child-process-interpolated-command", () => { code: `const { execFile } = require("child_process"); execFile("git " + branch, { shell: true });`, errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "execFile" } }], }, + { + code: `import { execSync, "exec" as run } from "child_process"; execSync(\`git checkout \${branch}\`); run("git checkout " + branch);`, + languageOptions: { sourceType: "module" }, + errors: [ + { messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "execSync" } }, + { messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }, + ], + }, ], }); }); diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.ts index 812c213e60c..f06d6a99564 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.ts @@ -26,28 +26,57 @@ function getDynamicCommandKind(node: TSESTree.Expression): string | null { return null; } -function isShellTrueOption(optionsArg: TSESTree.CallExpressionArgument | undefined): boolean { - if (!optionsArg || optionsArg.type === AST_NODE_TYPES.SpreadElement || optionsArg.type !== AST_NODE_TYPES.ObjectExpression) return false; - +function getShellPropertyValue(optionsArg: TSESTree.ObjectExpression): boolean { for (const prop of optionsArg.properties) { if (prop.type !== AST_NODE_TYPES.Property || prop.computed) continue; const keyName = prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name : prop.key.type === AST_NODE_TYPES.Literal ? prop.key.value : null; if (keyName !== "shell") continue; - return prop.value.type === AST_NODE_TYPES.Literal && prop.value.value === true; + return prop.value.type === AST_NODE_TYPES.Literal && !!prop.value.value; } return false; } +function resolveObjectExpression(arg: TSESTree.CallExpressionArgument, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): TSESTree.ObjectExpression | null { + if (arg.type === AST_NODE_TYPES.ObjectExpression) return arg; + if (arg.type !== AST_NODE_TYPES.Identifier) return null; + + let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode); + while (scope) { + const variable = scope.set.get(arg.name); + if (variable && variable.defs.length > 0) { + for (const def of variable.defs) { + if (def.type !== "Variable") continue; + const declarator = def.node as TSESTree.VariableDeclarator; + if (declarator.id.type !== AST_NODE_TYPES.Identifier || declarator.id.name !== arg.name) continue; + if (declarator.init?.type === AST_NODE_TYPES.ObjectExpression) return declarator.init; + } + return null; + } + scope = scope.upper; + } + + return null; +} + +function isShellTrueOption(optionsArg: TSESTree.CallExpressionArgument | undefined, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean { + if (!optionsArg) return false; + if (optionsArg.type === AST_NODE_TYPES.SpreadElement) return true; + + const resolvedObject = resolveObjectExpression(optionsArg, scopeNode, sourceCode); + if (!resolvedObject) return false; + return getShellPropertyValue(resolvedObject); +} + function requiresShellTrue(method: ChildProcessMethod): boolean { return SHELL_CONDITIONAL_METHODS.has(method); } -function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod): boolean { +function hasShellTrueOptions(node: TSESTree.CallExpression, method: ChildProcessMethod, sourceCode: TSESLint.SourceCode): boolean { if (!requiresShellTrue(method)) return true; - return isShellTrueOption(node.arguments[1]) || isShellTrueOption(node.arguments[2]); + return isShellTrueOption(node.arguments[1], node, sourceCode) || isShellTrueOption(node.arguments[2], node, sourceCode); } function isChildProcessMethodBinding(method: ChildProcessMethod, identifierName: string, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean { @@ -57,7 +86,7 @@ function isChildProcessMethodBinding(method: ChildProcessMethod, identifierName: if (variable && variable.defs.length > 0) { for (const def of variable.defs) { if (isChildProcessImportBinding(def) && def.node.type === AST_NODE_TYPES.ImportSpecifier) { - const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier ? def.node.imported.name : null; + const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier ? def.node.imported.name : def.node.imported.type === AST_NODE_TYPES.Literal ? def.node.imported.value : null; if (importedName === method) return true; } @@ -128,7 +157,7 @@ export const noChildProcessInterpolatedCommandRule = createRule({ CallExpression(node) { const method = resolveChildProcessMethod(node, sourceCode); if (!method) return; - if (!hasShellTrueOptions(node, method)) return; + if (!hasShellTrueOptions(node, method, sourceCode)) return; const firstArg = node.arguments[0]; if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; From ec4994886ec5a70fa4f893de7c7e92f3f41eef4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:34:51 +0000 Subject: [PATCH 4/6] Refine child_process rule readability and conservative spread handling note Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../src/rules/no-child-process-interpolated-command.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.ts index f06d6a99564..cc466a4afaa 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.ts @@ -26,6 +26,12 @@ function getDynamicCommandKind(node: TSESTree.Expression): string | null { return null; } +function getImportSpecifierName(node: TSESTree.ImportSpecifier): string | null { + if (node.imported.type === AST_NODE_TYPES.Identifier) return node.imported.name; + if (node.imported.type === AST_NODE_TYPES.Literal && typeof node.imported.value === "string") return node.imported.value; + return null; +} + function getShellPropertyValue(optionsArg: TSESTree.ObjectExpression): boolean { for (const prop of optionsArg.properties) { if (prop.type !== AST_NODE_TYPES.Property || prop.computed) continue; @@ -63,7 +69,7 @@ function resolveObjectExpression(arg: TSESTree.CallExpressionArgument, scopeNode function isShellTrueOption(optionsArg: TSESTree.CallExpressionArgument | undefined, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean { if (!optionsArg) return false; - if (optionsArg.type === AST_NODE_TYPES.SpreadElement) return true; + if (optionsArg.type === AST_NODE_TYPES.SpreadElement) return true; // Conservative: spread arguments are treated as possibly shell-enabled. const resolvedObject = resolveObjectExpression(optionsArg, scopeNode, sourceCode); if (!resolvedObject) return false; @@ -86,7 +92,7 @@ function isChildProcessMethodBinding(method: ChildProcessMethod, identifierName: if (variable && variable.defs.length > 0) { for (const def of variable.defs) { if (isChildProcessImportBinding(def) && def.node.type === AST_NODE_TYPES.ImportSpecifier) { - const importedName = def.node.imported.type === AST_NODE_TYPES.Identifier ? def.node.imported.name : def.node.imported.type === AST_NODE_TYPES.Literal ? def.node.imported.value : null; + const importedName = getImportSpecifierName(def.node); if (importedName === method) return true; } From 7069326de623b01a1a19814b06e77fdd3dfe0382 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:38:01 +0000 Subject: [PATCH 5/6] Document conservative spread handling and adjust shell literal check Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- eslint-factory/README.md | 1 + .../src/rules/no-child-process-interpolated-command.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint-factory/README.md b/eslint-factory/README.md index b119d4311be..b7c27f069e6 100644 --- a/eslint-factory/README.md +++ b/eslint-factory/README.md @@ -445,6 +445,7 @@ Why: command strings evaluated by a shell (`exec`, `execSync`, `spawn` / `spawnS - `const run = cp.execSync; run("git checkout " + branch)` — member alias call. - `spawn(\`git checkout ${branch}\`, { shell: true })` — shell-enabled spawn. - `execFileSync("git " + branch, ["status"], { shell: true })` — shell-enabled execFileSync. +- `spawn("git checkout " + branch, ...opts)` — spread options are treated conservatively as potentially shell-enabled. - ESM imports are recognized (`import { execSync } from "node:child_process"`). **Not flagged:** diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.ts index cc466a4afaa..8e2fbff66ff 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.ts @@ -39,7 +39,7 @@ function getShellPropertyValue(optionsArg: TSESTree.ObjectExpression): boolean { const keyName = prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name : prop.key.type === AST_NODE_TYPES.Literal ? prop.key.value : null; if (keyName !== "shell") continue; - return prop.value.type === AST_NODE_TYPES.Literal && !!prop.value.value; + return prop.value.type === AST_NODE_TYPES.Literal && prop.value.value !== false; } return false; From 539b5be59bf4ecb6aa718ab8319d17aedfd41ae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:40:58 +0000 Subject: [PATCH 6/6] Narrow shell literal detection to true or string values Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../src/rules/no-child-process-interpolated-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.ts index 8e2fbff66ff..2f3f4fd98a6 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.ts @@ -39,7 +39,7 @@ function getShellPropertyValue(optionsArg: TSESTree.ObjectExpression): boolean { const keyName = prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name : prop.key.type === AST_NODE_TYPES.Literal ? prop.key.value : null; if (keyName !== "shell") continue; - return prop.value.type === AST_NODE_TYPES.Literal && prop.value.value !== false; + return prop.value.type === AST_NODE_TYPES.Literal && (prop.value.value === true || typeof prop.value.value === "string"); } return false;