Skip to content

prefer-actions-exec-over-child-process: blanket suggestion ignores shell semantics of exec()/execSync() vs @actions/exec's non #58738

Description

@github-actions

Summary

prefer-actions-exec-over-child-process (eslint-factory/src/rules/prefer-actions-exec-over-child-process.ts) treats all four OUTPUT_CAPTURING_METHODS (exec, execSync, execFile, execFileSync) identically and always suggests migrating to @actions/exec's exec()/getExecOutput().

But child_process.exec()/execSync() (unlike execFile/execFileSync) run the given command through a shell, accepting a single string with full shell syntax: pipes, &&/||, redirection (2>/dev/null), globbing, etc. @actions/exec's exec()/getExecOutput() do not invoke a shell by default — they take a commandLine string split into program + args, or an explicit args array; shell metacharacters in the command string are not interpreted unless the caller explicitly passes { shell: true } (which changes semantics and reintroduces the same shell-injection surface @actions/exec is usually chosen to avoid).

So for exec/execSync call sites whose single command-string argument contains shell operators, the rule's message ("Prefer @actions/exec's exec()/getExecOutput() over child_process.{{method}}()...") is actively misleading: a literal drop-in swap breaks the command's behavior, rather than needing just an async-conversion — a different, more serious caveat than the one already carved out for non-async callers.

Grounded evidence

actions/setup/js/validate_secrets.cjs (github-script module, marker present)

Lines 18-19 and 252:

const { exec } = require("child_process");
const execAsync = promisify(exec);
...
const { stdout, stderr } = await execAsync('which copilot 2>/dev/null || echo ""');

The rule resolves execAsync back through promisify(exec) to { method: "exec", promisified: true } and (correctly, per current logic) reports it with the plain preferActionsExec message, since a promisified binding is treated as real @actions/exec-equivalent territory. But the 2>/dev/null suppression and the || echo "" fallback only work because child_process.exec() runs the string through /bin/sh -c. Rewriting this literally as exec.getExecOutput("which", ["copilot"]) silently drops both behaviors - the migration is not the mechanical swap the message implies.

Ask

  • Detect when the resolved method is exec/execSync (shell-based) and the command argument is a single string containing shell metacharacters (pipes, &&/||, redirection, globbing, subshells, etc.) - either suppress the suggestion for that shape or emit a distinct message noting the shell-syntax caveat (that @actions/exec needs { shell: true } or a rewrite into an args-array plus explicit logic to replicate the shell behavior).
  • At minimum, document this caveat in the rule's docs.description and README entry so the suggestion isn't read as a literal 1:1 replacement for shell-syntax commands.

Acceptance criteria

  • Add a test case modeled on validate_secrets.cjs's promisified exec('which copilot 2>/dev/null || echo ""') (or a direct execSync("a | b")) demonstrating either a suppressed report or a distinct shell-caveat message.
  • Add a test case confirming a shell-metacharacter-free single-string command (e.g. execSync("git status")) is unaffected - still reported with the existing message.
  • No regressions in the existing prefer-actions-exec-over-child-process.test.ts suite.

Scope note

Filed under the ESLint Refiner mission for eslint-factory/**, targeting actions/setup/js/** as the grounding corpus.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • api.anthropic.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "api.anthropic.com"

See Network Configuration for more information.

Generated by 🤖 ESLint Refiner · claude · agent · 357 AIC · ⌖ 7.82 AIC · ⊞ 5.8K ·

  • expires on Sep 11, 2026, 9:35 PM UTC-08:00

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions