Skip to content

fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command#47544

Merged
pelikhan merged 4 commits into
mainfrom
copilot/eslint-refiner-fix-single-level-command
Jul 23, 2026
Merged

fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command#47544
pelikhan merged 4 commits into
mainfrom
copilot/eslint-refiner-fix-single-level-command

Conversation

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

no-exec-interpolated-command only inspected the syntactic shape of arguments[0], so storing a dynamic command in a variable before passing it to exec.exec() fully evaded detection.

Changes

  • no-exec-interpolated-command.ts: Added resolveInitializer() inside create() that walks the scope chain and returns a variable's initializer when the binding is a write-once Variable definition (parameters, imports, multiply-assigned vars, and unresolvable identifiers are all skipped). When arguments[0] is an Identifier, the resolved initializer is checked instead of the identifier itself.

  • no-exec-interpolated-command.test.ts: Added new invalid cases for the variable-indirection pattern and new valid cases confirming static-string variables, reassigned variables, and function parameters are not flagged.

Now flagged

const cmd = `git checkout ${branch}`;
exec.exec(cmd, []);  // flagged: interpolated template literal via variable

const cmd2 = "git checkout " + branchName;
exec.exec(cmd2, []); // flagged: dynamic string concatenation via variable

Still valid

const cmd = "git";
exec.exec(cmd, [branch]);  // static string — safe

let cmd = "git"; cmd = "other";
exec.exec(cmd, [branch]);  // reassigned — skipped to avoid false positives

Generated by 👨‍🍳 PR Sous Chef · gpt54 5.28 AIC · ⌖ 6.94 AIC · ⊞ 7K ·
Comment /souschef to run again

…exec-interpolated-command

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix false negative in no-exec-interpolated-command rule fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command Jul 23, 2026
Copilot AI requested a review from pelikhan July 23, 2026 10:44
@pelikhan
pelikhan marked this pull request as ready for review July 23, 2026 10:48
Copilot AI review requested due to automatic review settings July 23, 2026 10:48
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds single-level variable resolution to detect dynamically constructed commands passed to exec.exec().

Changes:

  • Resolves write-once variable initializers before command analysis.
  • Adds valid and invalid variable-indirection tests.
Show a summary per file
File Description
no-exec-interpolated-command.ts Adds initializer resolution.
no-exec-interpolated-command.test.ts Tests variable-based commands.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Medium

const declarator = def.node as TSESTree.VariableDeclarator;
return declarator.init ?? null;
}
scope = scope.upper;
// Variable holds a static template literal — safe
{ code: "const cmd = `git`; exec.exec(cmd, [branch]);" },
// Reassigned variable — skipped to avoid false positives
{ code: `let cmd = "git"; cmd = "other"; exec.exec(cmd, [branch]);` },
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #47544 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks correct and well-tested.

  • resolveInitializer properly walks the scope chain and returns null for parameters, imports, multiply-assigned vars, and unresolvable identifiers
  • The fallback resolveInitializer(firstArg) ?? firstArg preserves existing detection behavior for direct calls
  • ref.isWrite() && !ref.init correctly distinguishes reassignments from initializers
  • Test cases cover all new branches (static-string variables, reassigned vars, parameters, interpolated via variable, concatenation via variable)

No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 18.2 AIC · ⌖ 7.93 AIC · ⊞ 5K

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 6 test case(s): 6 design, 0 implementation, 0 violation(s).

📊 Metrics (6 test cases)
Metric Value
Analyzed 6 (JavaScript/vitest)
✅ Design 6 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 6 (100%)
Duplicate clusters 0
Inflation ratio 0.56:1 ✓
🚨 Violations 0
Test File Classification Issues
Static string indirection test.ts:38 design_test None
Static template indirection test.ts:40 design_test None
Reassigned variable edge case test.ts:42 design_test None
Parameter shadowing edge case test.ts:44 design_test None
Interpolated template flag (indirection) test.ts:78 design_test None
Dynamic concatenation flag (indirection) test.ts:83 design_test None

Verdict

Approved. All 6 new test cases are focused behavioral contracts that enforce the vulnerability model (single-level variable indirection resolution in exec commands). Tests comprehensively cover:

  • Safe patterns: Static strings, static templates, reassignments, parameters
  • Unsafe patterns: Interpolated templates and dynamic concatenation through variable indirection
  • Quality signal: 100% design tests, no inflation, no duplicates

The fix resolves the core vulnerability: preventing dangerous shell interpolation patterns even when hidden behind a single variable reference.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 16.6 AIC · ⌖ 7.72 AIC · ⊞ 7.1K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. All 6 test cases are design-focused behavioral contracts with 100% edge/error coverage. No inflation, no duplicates, no violations. Approved.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnosing-bugs, /tdd, and /codebase-design — 2 minor suggestions, no blockers.

📋 Key Themes & Highlights

Key Themes

  • Missing boundary test: the intentional single-level-only scope is undocumented by a test, making the limit invisible to future contributors.
  • Inline complexity: the resolution + fallback on one line reduces readability.

Positive Highlights

  • ✅ Root cause correctly addressed — scope-chain walk resolves write-once variable initializers without false positives on reassigned vars, parameters, or imports.
  • ✅ Conservative guards (defs.length !== 1, def.type !== "Variable", write-only ref check) prevent over-flagging.
  • ✅ Good test coverage of the new happy paths and the new invalid cases.
  • ✅ PR description is clear and matches the implementation exactly.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 33 AIC · ⌖ 4.71 AIC · ⊞ 6.7K
Comment /matt to run again

code: `const cmd = "git checkout " + branchName; exec.exec(cmd, []);`,
errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }],
},
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing boundary test: two-level indirection (e.g. const a = \git ${x}`; const b = a; exec.exec(b, [])`) is intentionally out of scope, but there is no test documenting this limit.

💡 Suggested valid test
// Two-level indirection — single-level resolution only, not flagged
{ code: "const a = `git ${x}`; const b = a; exec.exec(b, []);", },

Without a test, the intended boundary is invisible to reviewers of future follow-up PRs.

@copilot please address this.

// Resolve a single level of variable indirection so that
// const cmd = `git checkout ${branch}`; exec.exec(cmd, []);
// is flagged in the same way as the direct form.
const candidate = firstArg.type === AST_NODE_TYPES.Identifier ? (resolveInitializer(firstArg) ?? firstArg) : (firstArg as TSESTree.Expression);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] This line is doing three things at once (type narrowing, resolution, fallback). Extracting to a named variable would make the intent clearer and easier to maintain.

💡 Suggested refactor
const resolvedArg =
  firstArg.type === AST_NODE_TYPES.Identifier
    ? (resolveInitializer(firstArg) ?? firstArg)
    : firstArg;
const kind = getDynamicCommandKind(resolvedArg as TSESTree.Expression);

Minor, but the current one-liner hides the fallback logic on an already-busy line.

@copilot please address this.

@github-actions github-actions Bot mentioned this pull request Jul 23, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — two correctness gaps must be fixed before merge

Blocking issues
  1. Chained aliasing bypasses the rule (high) — resolveInitializer is called exactly once, so const a = cmd; const cmd = \git ${x}`` is a silent false negative. Either loop until stable or explicitly document the known limitation.
  2. Scope walk climbs into module/global scope (medium) — resolving a module-level variable's initializer is not a safe proxy for its runtime value; the mutation guard cannot see async or cross-module writes. Restrict resolution to local function-scoped bindings.

The one-level indirection check is the correct direction, but the above gaps undermine its security guarantee.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 40.2 AIC · ⌖ 6.5 AIC · ⊞ 5.7K
Comment /review to run again

// Resolve a single level of variable indirection so that
// const cmd = `git checkout ${branch}`; exec.exec(cmd, []);
// is flagged in the same way as the direct form.
const candidate = firstArg.type === AST_NODE_TYPES.Identifier ? (resolveInitializer(firstArg) ?? firstArg) : (firstArg as TSESTree.Expression);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-level indirection only — chained aliasing silently bypasses the rule.

💡 Details and suggested fix

resolveInitializer is called exactly once on arguments[0]. If a user writes:

const bad = \`git checkout ${branch}\`;
const cmd = bad;        // one extra alias
exec.exec(cmd, []);     // NOT flagged

The rule resolves cmdbad (an Identifier), then calls getDynamicCommandKind(bad) which returns null because bad is an identifier, not a template literal.

Fix: loop until stable, or add an explicit TODO comment documenting the known gap.

let candidate: TSESTree.Expression =
  firstArg.type === AST_NODE_TYPES.Identifier
    ? (resolveInitializer(firstArg) ?? firstArg)
    : firstArg;
while (candidate.type === AST_NODE_TYPES.Identifier) {
  const resolved = resolveInitializer(candidate);
  if (!resolved) break;
  candidate = resolved;
}

/**
* When `identifier` is a write-once local variable binding, returns its
* initializer expression so the caller can apply further checks. Returns
* null for parameters, imports, multiply-assigned vars, and vars with no

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope walk climbs past function boundaries — module-level variable initializers are not statically safe.

💡 Details and suggested fix

The while (scope !== null) loop walks up through function and module scopes. A module-level let or var can be mutated at runtime, so its initializer is not a reliable proxy for its value at call time.

The existing isWrite() && !ref.init guard only detects reassignments visible in the same scope analysis graph; external mutation (e.g., from an async callback) is invisible to this analysis.

Fix: stop climbing at the enclosing function boundary so only local block-scoped bindings are resolved.

// stop at function/module boundaries
if (scope.type === 'function' || scope.type === 'module') {
  break;
}
scope = scope.upper;

// Resolve a single level of variable indirection so that
// const cmd = `git checkout ${branch}`; exec.exec(cmd, []);
// is flagged in the same way as the direct form.
const candidate = firstArg.type === AST_NODE_TYPES.Identifier ? (resolveInitializer(firstArg) ?? firstArg) : (firstArg as TSESTree.Expression);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dense ternary with unnecessary cast — split for readability.

💡 Suggested fix

The candidate line crams an as TSESTree.Expression cast into the else branch that serves no purpose — after the SpreadElement guard above, firstArg is already narrowed to TSESTree.Expression. The cast masks the type narrowing and makes the code harder to review.

Prefer an explicit if/else:

let candidate: TSESTree.Expression;
if (firstArg.type === AST_NODE_TYPES.Identifier) {
  candidate = resolveInitializer(firstArg) ?? firstArg;
} else {
  candidate = firstArg;
}

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill and address the unresolved blocking review feedback before requesting maintainer attention.

Unresolved review feedback:

Generated by 👨‍🍳 PR Sous Chef · gpt54 5.28 AIC · ⌖ 6.94 AIC · ⊞ 7K ·
Comment /souschef to run again

…ries

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill and address the unresolved blocking review feedback before requesting maintainer attention.

Unresolved review feedback:...

Addressed the unresolved blocking feedback in commit 897ee36.

Implemented:

  • function-boundary-only initializer resolution (no cross-function/module traversal)
  • recursive in-function alias resolution with cycle protection
  • regression coverage for closure boundary, reassigned dynamic initializer, same-function nested block resolution, and chained aliases

Validation run:

  • targeted rule test: eslint-factory/src/rules/no-exec-interpolated-command.test.ts
  • make agent-report-progress passed

CI on this new head is stale until a maintainer re-triggers workflows.

Copilot AI requested a review from gh-aw-bot July 23, 2026 11:34
@pelikhan
pelikhan removed their request for review July 23, 2026 11:36
@pelikhan
pelikhan merged commit 3e9015b into main Jul 23, 2026
@pelikhan
pelikhan deleted the copilot/eslint-refiner-fix-single-level-command branch July 23, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[eslint-refiner] no-exec-interpolated-command: resolve single-level command-variable indirection (false negative)

4 participants