From c57e0d56c27f6ed8a2ca7f62b0ac50b470a013a2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 25 Jun 2026 16:25:48 -0400 Subject: [PATCH] feat(#1720): no-unguarded-nonportable-exec AST rule; retire the regex script (Phase 3) (#1723) Phase 3 of epic #1702. Closes #1720. --- CONTEXT.md | 7 +- ...03-portability-enforcement-architecture.md | 21 +- .../cross-platform-portability-rules.md | 29 ++ .../no-unguarded-nonportable-exec.cjs | 258 +++++++++++++++ eslint.config.mjs | 4 + .../CONTEXT-INDEX.json | 6 +- package.json | 3 +- scripts/lint-windows-test-portability.cjs | 178 ----------- scripts/prompt-injection-scan.sh | 4 + tests/lint-windows-test-portability.test.cjs | 136 -------- ...o-unguarded-nonportable-exec.rule.test.cjs | 300 ++++++++++++++++++ tests/portability-rule-disable-ban.test.cjs | 2 +- 12 files changed, 616 insertions(+), 332 deletions(-) create mode 100644 eslint-rules/no-unguarded-nonportable-exec.cjs delete mode 100644 scripts/lint-windows-test-portability.cjs delete mode 100644 tests/lint-windows-test-portability.test.cjs create mode 100644 tests/no-unguarded-nonportable-exec.rule.test.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 450f735c8..e931cfc67 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -730,9 +730,9 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally` `DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes` -`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done` -`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional` -`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` +`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done` +`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)` +`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755` `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact` @@ -813,6 +813,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets `DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration` `DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths` `DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform` +`DEFECT.WINDOWS-ARGV-OVERFLOW.prevention=a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)` `DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom=a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with "Cannot find module" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT` `DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002` diff --git a/docs/adr/1703-portability-enforcement-architecture.md b/docs/adr/1703-portability-enforcement-architecture.md index 244e1428c..1adfd4800 100644 --- a/docs/adr/1703-portability-enforcement-architecture.md +++ b/docs/adr/1703-portability-enforcement-architecture.md @@ -74,7 +74,6 @@ Replace all three with a single coherent mechanism: **AST-based ESLint rules in | `no-hardcoded-tmp` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G4) | tests | | `no-bare-npm-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G5) | tests | | `require-userprofile-with-home` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G6) | tests | -| `no-oversized-test-argv` | `DEFECT.WINDOWS-ARGV-OVERFLOW` | tests | | `normalize-path-in-content` | `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` (`RULESET.CONTENT-PATH-NORMALIZATION`) | `src/**/*.cts` | | `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install | @@ -85,11 +84,13 @@ fence-match shape — covered by `no-crlf-fragile-split`; and (b) feeding a Wind path into a Git Bash glob / `bash -c` — covered jointly by `no-hardcoded-tmp` (steer tmp usage) and `no-unguarded-nonportable-exec` (require a platform guard on `bash -c`). The residual runtime Git-Bash path-translation behavior is not fully statically decidable; the rules catch the source -shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` (a test assembling an argv that exceeds the -Windows command-length limit) is detected heuristically by `no-oversized-test-argv` — it flags -very large `.repeat(N)`/concatenated literals passed to a `child_process` exec call; because the -true limit is a runtime property, this rule is an over-approximation tripwire, documented as -such, landing in Phase 3. +shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately +**not** in this catalog: it is a *runtime* argv-length property (the args-array size is not +statically knowable — e.g. `execFileSync('node', [...N runtime paths])`), so no AST rule can +soundly detect it. Phase 3 evaluated a `no-oversized-test-argv` heuristic and **dropped it as +unsound** (it could only catch a contrived literal `.repeat(N)` command string, never the +canonical array overflow). The class is addressed at the source: the production `run-tests.cjs` +argv chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`, with its anchor `tests/run-tests-harness.test.cjs`. ### Architecture @@ -175,9 +176,11 @@ phasing (one rule at a time, each independently reviewed and shipped) and by the they are first needed. - **Phase 4** the G1–G6 rules + fix all grandfathered offenders + delete the ratchet test. - **Phase 5–6** production `normalize-path-in-content`, `require-fs-op-fallback`. -- **Phase 7** teardown: delete the regex script + allowlist-ratchet usage + the opt-out convention; - rewrite `CONTEXT.md` `DEFECT.WINDOWS-*` predicates to point at the rules; the forward - architecture guide ("how to add a portability rule"). +- **Phase 7** teardown: delete the `windows-test-parity-guard` ratchet + `allowlist-ratchet` usage + for these classes + sweep any residual `// windows-portability-ok:` comments; finalize the + `CONTEXT.md` `DEFECT.WINDOWS-*` predicate rewrite; the forward architecture guide ("how to add a + portability rule"). (The regex script `scripts/lint-windows-test-portability.cjs` was retired + earlier — in Phase 3 — as its `no-unguarded-nonportable-exec` replacement landed.) Each implementation phase runs the full engineering directive (rubber-duck → laws → architecture → qa-test-architect → strict TDD via `RuleTester` → codex adversarial → Diátaxis → rebase+PR) diff --git a/docs/contributing/cross-platform-portability-rules.md b/docs/contributing/cross-platform-portability-rules.md index 4895a59c6..4e18cbb18 100644 --- a/docs/contributing/cross-platform-portability-rules.md +++ b/docs/contributing/cross-platform-portability-rules.md @@ -18,6 +18,7 @@ running outside ESLint, fails the build if you try). Legitimately platform-speci |---|---|---| | `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` | | `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` | +| `local/no-unguarded-nonportable-exec` | A file that **both** sets a chmod exec-bit (`chmod`/`chmodSync` with `0oNNN & 0o111 !== 0`) **and** invokes `sh`/`bash` with a `-c` flag (`execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. | `tests/**/*.test.cjs` | (More rules land per the epic — see ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702).) @@ -71,6 +72,34 @@ assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere Prefer asserting the *behavior* (command shape, runnability) over the raw mode bit where you can. +## How-to — fix a `no-unguarded-nonportable-exec` violation + +Why it fails on Windows: Windows Git Bash (msys2) does not honour Node's chmod exec bit for +extension-less scripts that are invoked by searching PATH. A test that makes a fixture executable +with `chmodSync(p, 0o755)` and then runs it with `execFileSync('bash', ['-c', '...'])` passes on +macOS/Linux but fails only on the `windows-latest` CI lane (DEFECT.WINDOWS-TEST-PORTABILITY). + +**Fix option A: gate the `sh`/`bash -c` invocation behind a platform check** + +```js +// ❌ flagged +fs.chmodSync(fixture, 0o755); +execFileSync('bash', ['-c', './fixture run']); + +// ✅ platform-guarded +fs.chmodSync(fixture, 0o755); +if (process.platform !== 'win32') { + execFileSync('bash', ['-c', './fixture run']); +} +``` + +**Fix option B: invoke the script with an explicit interpreter (no -c flag)** + +```js +// ✅ passes the script path directly — exec bit not needed +execFileSync('sh', [fixturePath]); +``` + ## Platform guards (the only "escape" — by structure, not annotation) If an assertion is *genuinely* POSIX-only, gate it behind a Windows platform check the rule diff --git a/eslint-rules/no-unguarded-nonportable-exec.cjs b/eslint-rules/no-unguarded-nonportable-exec.cjs new file mode 100644 index 000000000..1865ee67d --- /dev/null +++ b/eslint-rules/no-unguarded-nonportable-exec.cjs @@ -0,0 +1,258 @@ +'use strict'; + +/** + * no-unguarded-nonportable-exec + * + * Flag test files that BOTH make a fixture executable via chmod (exec-bit set) + * AND invoke it with `sh -c` / `bash -c` — without a Windows platform guard. + * + * ## Why + * + * Windows Git Bash (msys2) does not honour Node's chmod exec bit for + * PATH-executing extension-less scripts. A test that (a) makes a fixture + * executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass + * on Mac/Linux but fail only in the CI `test (windows-latest, *)` / + * `full test (windows-latest, *)` lanes, producing a hard-to-diagnose + * false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY. + * + * ## What this enforces (Program-level co-occurrence) + * + * Within a single file, detects the combination: + * - makesExecutable: any `chmod`/`chmodSync(path, 0oNNN)` call where the + * octal 2nd arg has exec bits set (`0oNNN & 0o111 !== 0`) + * - shellDashC: any `execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync` + * call whose command arg is `sh`/`bash`/`/bin/sh`/`/bin/bash` with a `-c` + * arg in array form — or a string literal arg containing `sh -c`/`bash -c` + * + * At Program:exit, reports each unguarded shellDashC node when the file also + * contains a chmod-exec-bit call. Guarded means the node is inside an + * `isWindowsExcludedNode` block (platform guard / early-return / hoisted isWindows). + * + * ## Remediation + * + * Gate the bare-command execution behind `if (process.platform !== 'win32')`, + * or invoke via an explicit interpreter (`sh ` instead of `sh -c `). + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + */ + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow unguarded chmod exec-bit + sh/bash -c combinations in tests (fails on Windows Git Bash)', + category: 'Portability', + }, + schema: [], + messages: { + nonportableExec: + 'chmod exec-bit + sh/bash -c without a Windows guard ' + + '(DEFECT.WINDOWS-TEST-PORTABILITY): Windows Git Bash (msys2) ignores ' + + "the exec bit for PATH-executed extension-less scripts. Gate the " + + "execution on `if (process.platform !== 'win32')` or invoke via an " + + 'explicit interpreter `sh ` instead of `sh -c`.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + /** + * Shell commands whose first argument is the shell name. + * Matches execFileSync, spawnSync, spawn, exec, execSync. + */ + const SHELL_EXEC_FN_NAMES = new Set([ + 'execFileSync', + 'spawnSync', + 'spawn', + 'exec', + 'execSync', + ]); + + /** + * Bare shell names (possibly with /bin/ or /usr/bin/ prefix). + * The path prefix is stripped when comparing. + */ + const SHELL_NAMES = new Set(['sh', 'bash']); + + /** + * Returns the string value of a node if it's a string literal, else null. + * @param {import('eslint').Rule.Node} node + * @returns {string|null} + */ + function stringValue(node) { + if (node && node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return null; + } + + /** + * Returns true if `name` (possibly /bin/sh or /usr/bin/bash etc.) is sh/bash. + * @param {string} name + * @returns {boolean} + */ + function isShellName(name) { + // Strip /bin/ or /usr/bin/ prefix + const bare = name.replace(/^(?:\/usr)?\/bin\//, ''); + return SHELL_NAMES.has(bare); + } + + /** + * Returns true when this CallExpression is a `sh`/`bash -c` invocation in + * array form: + * execFileSync('bash', ['-c', ...]) + * spawnSync('/bin/sh', ['-c', ...]) + * etc. + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isShellDashCArrayForm(node) { + if (node.type !== 'CallExpression') return false; + + // Callee must be one of our shell exec functions (possibly member expr) + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false; + + const args = node.arguments; + if (!args || args.length < 2) return false; + + // First arg: shell name + const shellArg = stringValue(args[0]); + if (!shellArg || !isShellName(shellArg)) return false; + + // Second arg: must be an ArrayExpression containing '-c' + const secondArg = args[1]; + if (!secondArg || secondArg.type !== 'ArrayExpression') return false; + + // '-c' must be the FIRST element: sh/bash -c → args = ['-c', ] + // A script that happens to receive '-c' later (e.g. [fixturePath, '-c']) + // is NOT a shell -c invocation. + const firstEl = secondArg.elements[0]; + return stringValue(firstEl) === '-c'; + } + + /** + * Returns true when this CallExpression is a string-literal form containing + * `sh -c` or `bash -c`: + * exec('sh -c "run.sh"') + * execSync('bash -c script') + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isShellDashCStringForm(node) { + if (node.type !== 'CallExpression') return false; + + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false; + + const args = node.arguments; + if (!args || args.length < 1) return false; + + // First arg may be a string literal containing 'sh -c' or 'bash -c' + const firstArg = stringValue(args[0]); + if (!firstArg) return false; + + // Anchor to the start of the command string (allowing leading whitespace and + // an optional absolute-path prefix like /bin/ or /usr/bin/). + // This prevents matching 'sh -c' embedded mid-string in data, e.g. + // exec('printf "sh -c"') or exec('echo run sh -c later') + return /^\s*(?:\/\S+\/)?(?:bash|sh)\s+-c\b/.test(firstArg); + } + + /** + * Returns true when the CallExpression is a chmod/chmodSync call whose + * second arg is an octal literal with at least one exec bit set. + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isChmodExecBit(node) { + if (node.type !== 'CallExpression') return false; + + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || (fnName !== 'chmod' && fnName !== 'chmodSync')) return false; + + const args = node.arguments; + if (!args || args.length < 2) return false; + + const modeArg = args[1]; + if (!modeArg || modeArg.type !== 'Literal') return false; + if (typeof modeArg.value !== 'number') return false; + + // Check it's an octal literal (raw source starts with 0o or 0O) + const raw = sourceCode.getText(modeArg); + if (!raw.startsWith('0o') && !raw.startsWith('0O')) return false; + + // Check exec bit is set + return (modeArg.value & 0o111) !== 0; + } + + // ── Per-file state ────────────────────────────────────────────────────────── + + /** Whether the file contains at least one chmod exec-bit call. */ + let fileHasChmodExecBit = false; + + /** Collection of sh/bash -c nodes found in this file. */ + const shellDashCNodes = []; + + return { + CallExpression(node) { + if (isChmodExecBit(node)) { + fileHasChmodExecBit = true; + } + if (isShellDashCArrayForm(node) || isShellDashCStringForm(node)) { + shellDashCNodes.push(node); + } + }, + + 'Program:exit'() { + if (!fileHasChmodExecBit) return; + + for (const shellNode of shellDashCNodes) { + if (!isWindowsExcludedNode(shellNode, sourceCode)) { + context.report({ node: shellNode, messageId: 'nonportableExec' }); + } + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e0ddbb7d..26f7f26d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs'; import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs'; import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs'; import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs'; +import noUnguardedNonportableExec from './eslint-rules/no-unguarded-nonportable-exec.cjs'; const localPlugin = { rules: { @@ -28,6 +29,7 @@ const localPlugin = { 'no-adhoc-markdown-parsing': noAdhocMarkdownParsing, 'no-path-literal-in-assert': noPathLiteralInAssert, 'no-posix-mode-bit-assert': noPosixModeBitAssert, + 'no-unguarded-nonportable-exec': noUnguardedNonportableExec, }, }; @@ -273,6 +275,8 @@ export default tseslint.config( 'local/no-path-literal-in-assert': 'error', // Ban POSIX mode-bit assertions compared to octal literals (fails on Windows) 'local/no-posix-mode-bit-assert': 'error', + // Ban unguarded chmod exec-bit + sh/bash -c combos (fails on Windows Git Bash) + 'local/no-unguarded-nonportable-exec': 'error', // Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax 'no-restricted-syntax': [ 'error', diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 82028610d..674caf541 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -919,7 +919,7 @@ { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", - "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", + "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (local/no-unguarded-nonportable-exec now enforces this via ESLint) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", "line": 735 }, { @@ -931,7 +931,7 @@ { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", - "value": "npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done", + "value": "local/no-unguarded-nonportable-exec ESLint rule (enforced in tests/**/*.test.cjs via npm run lint); flags tests combining chmod exec-bit with sh/bash -c and no platform guard; watch CI windows matrix green before declaring a PR done", "line": 727 }, { @@ -943,7 +943,7 @@ { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", - "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional", + "value": "gate platform-specific execution with if (process.platform !== 'win32') — there is no opt-out annotation; structure any platform-specific code behind this guard; normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; see local/no-unguarded-nonportable-exec ESLint rule and docs/how-to/windows-portability.md for details", "line": 728 }, { diff --git a/package.json b/package.json index d99ca54ac..b5e44b550 100644 --- a/package.json +++ b/package.json @@ -94,9 +94,8 @@ "pretest:coverage": "npm run build:lib && npm run lint:skill-deps", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint:fix": "eslint . --fix", - "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", - "lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", "lint:skill-deps": "node scripts/lint-skill-deps.cjs", diff --git a/scripts/lint-windows-test-portability.cjs b/scripts/lint-windows-test-portability.cjs deleted file mode 100644 index 6994a447c..000000000 --- a/scripts/lint-windows-test-portability.cjs +++ /dev/null @@ -1,178 +0,0 @@ -'use strict'; - -/** - * lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit - * with bare sh/bash -c without a platform guard. - * - * ## Why - * - * Windows Git Bash (msys2) does not honour Node's chmod exec bit for - * PATH-executing extension-less scripts. A test that (a) makes a fixture - * executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass - * on Mac/Linux but fail only in the CI `test (windows-latest, *)` / - * `full test (windows-latest, *)` lanes, producing a hard-to-diagnose - * false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY. - * - * ## What this enforces - * - * For every file in tests/**\/*.test.cjs (recursive, excluding node_modules): - * - makesExecutable: contains chmodSync?( with an exec-bit octal literal - * - shellDashC: contains a sh/bash -c invocation (array form or string literal) - * - guarded: contains a process.platform / os.platform() / win32 / isWindows guard - * - optOut: contains the literal `windows-portability-ok` - * VIOLATION = makesExecutable && shellDashC && !guarded && !optOut - * - * ## Remediation - * - * Gate the bare-command execution with `if (process.platform !== 'win32')`, - * or invoke via an explicit interpreter (`sh `), or annotate - * `// windows-portability-ok: `. - * - * ## Export contract (for unit tests) - * - * When required as a module (`require.main !== module`) this file exports - * `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }. - */ - -const fs = require('fs'); -const path = require('path'); - -// ─── Detection regexes ────────────────────────────────────────────────────── - -/** - * Match chmod/chmodSync( calls with an octal mode literal whose exec bits are - * set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`. - */ -const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g; - -/** - * Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally - * prefixed) as the first arg and '-c' as an element of the args array. - * e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...]) - */ -const SHELL_ARRAY_RE = - /(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/; - -/** - * String-literal form: any string containing `bash -c` or `sh -c`. - */ -const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/; - -/** Platform guard presence. */ -const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/; - -/** Opt-out annotation. */ -const OPT_OUT_RE = /windows-portability-ok/; - -// ─── Pure scanning function (exported for unit tests) ──────────────────────── - -/** - * Scan a single file's source text and return detection flags. - * - * @param {string} source - The file contents as a string. - * @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }} - */ -function scanContent(source) { - // Reset stateful regex before use. - CHMOD_RE.lastIndex = 0; - - let makesExecutable = false; - let match; - while ((match = CHMOD_RE.exec(source)) !== null) { - const oct = match[1]; - if ((parseInt(oct, 8) & 0o111) !== 0) { - makesExecutable = true; - break; - } - } - - const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source); - const guarded = GUARD_RE.test(source); - const optOut = OPT_OUT_RE.test(source); - const violation = makesExecutable && shellDashC && !guarded && !optOut; - - return { makesExecutable, shellDashC, guarded, optOut, violation }; -} - -// ─── Filesystem walker ─────────────────────────────────────────────────────── - -/** - * Recursively collect all *.test.cjs files under `dir`, excluding node_modules. - * - * @param {string} dir - * @param {string[]} [acc] - * @returns {string[]} - */ -function collectTestFiles(dir, acc) { - acc = acc || []; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - if (entry.name === 'node_modules') continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - collectTestFiles(full, acc); - } else if (entry.isFile() && entry.name.endsWith('.test.cjs')) { - acc.push(full); - } - } - return acc; -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -function main() { - const ROOT = path.join(__dirname, '..'); - const TESTS_DIR = path.join(ROOT, 'tests'); - - const files = collectTestFiles(TESTS_DIR); - const violations = []; - - for (const file of files) { - let source; - try { - source = fs.readFileSync(file, 'utf8'); - } catch { - continue; - } - const { violation } = scanContent(source); - if (violation) { - const rel = path.relative(ROOT, file).replace(/\\/g, '/'); - violations.push(rel); - } - } - - if (violations.length > 0) { - for (const rel of violations) { - process.stderr.write( - `${rel}: chmod-executable + sh/bash -c with no platform guard\n`, - ); - } - process.stderr.write( - '\nWindows Git Bash does not honor Node\'s chmod exec bit for ' + - 'PATH-executing extension-less scripts ' + - '(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' + - 'Gate the bare-command execution with ' + - '`if (process.platform !== \'win32\')`, or invoke via an explicit ' + - 'interpreter (`sh `), or annotate ' + - '`// windows-portability-ok: `.\n', - ); - process.exitCode = 1; - } else { - console.log( - `ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`, - ); - } -} - -// ─── Module boundary ───────────────────────────────────────────────────────── - -if (require.main === module) { - main(); -} else { - module.exports = { scanContent }; -} diff --git a/scripts/prompt-injection-scan.sh b/scripts/prompt-injection-scan.sh index 3f9ff9fb9..3440507e7 100755 --- a/scripts/prompt-injection-scan.sh +++ b/scripts/prompt-injection-scan.sh @@ -94,6 +94,10 @@ ALLOWLIST=( # real injection payloads to prove the validator rejects them. See # DEFECT.PROMPT-INJECTION-SCAN-COLLISION in CONTEXT.md. 'tests/windsurf-conversion.test.cjs' + # RuleTester fixtures for the local/no-unguarded-nonportable-exec ESLint rule + # contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…])) + # as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720). + 'tests/no-unguarded-nonportable-exec.rule.test.cjs' ) is_allowlisted() { diff --git a/tests/lint-windows-test-portability.test.cjs b/tests/lint-windows-test-portability.test.cjs deleted file mode 100644 index e3d925a6c..000000000 --- a/tests/lint-windows-test-portability.test.cjs +++ /dev/null @@ -1,136 +0,0 @@ -// windows-portability-ok: fixture strings for the lint's own unit test, not real execution -'use strict'; - -/** - * Tests for scripts/lint-windows-test-portability.cjs - * - * Uses the exported `scanContent` pure function to avoid spawning real - * subprocesses or touching the filesystem. This keeps the test portable and - * prevents the lint from flagging itself (the opt-out comment above covers the - * chmod/bash-c fixture strings below). - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const { scanContent } = require('../scripts/lint-windows-test-portability.cjs'); - -describe('lint-windows-test-portability: scanContent', () => { - test('(a) chmod 0o755 + bash -c with no guard => violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'echo hi']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.guarded, false, 'guarded'); - assert.strictEqual(result.optOut, false, 'optOut'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('(b) chmod 0o755 + bash -c + process.platform guard => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'echo hi']); - if (process.platform !== 'win32') { runIt(); } - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.guarded, true, 'guarded'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(c) chmod 0o644 (no exec bit) + bash -c => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o644); - execFileSync('bash', ['-c', 'cat file']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(d) chmod 0o755 + execFileSync(sh, [path]) with no -c => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('sh', [fixturePath]); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, false, 'shellDashC'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(e) violation pattern + windows-portability-ok opt-out => no violation', () => { - const src = ` - // windows-portability-ok: intentional cross-platform test - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.optOut, true, 'optOut'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('chmod 0o111 (pure exec bits) is detected as executable', () => { - const src = `fs.chmodSync(f, 0o111); spawnSync('sh', ['-c', 'x']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('chmod 0o444 (read-only) is not executable', () => { - const src = `fs.chmodSync(f, 0o444); execFileSync('bash', ['-c', 'x']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('string-literal sh -c form is detected', () => { - const src = ` - fs.chmodSync(f, 0o755); - exec('sh -c "run.sh"'); - `; - const result = scanContent(src); - assert.strictEqual(result.shellDashC, true, 'shellDashC from string literal'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('/bin/bash prefix in array form is detected', () => { - const src = ` - fs.chmodSync(f, 0o755); - execFileSync('/bin/bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.shellDashC, true, 'shellDashC with /bin/bash prefix'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('isWindows guard suppresses violation', () => { - const src = ` - const isWindows = process.platform === 'win32'; - fs.chmodSync(f, 0o755); - execFileSync('bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.guarded, true, 'guarded via isWindows'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('no chmod at all => no violation regardless of shell -c', () => { - const src = `execFileSync('bash', ['-c', 'echo hi']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.violation, false, 'violation'); - }); -}); diff --git a/tests/no-unguarded-nonportable-exec.rule.test.cjs b/tests/no-unguarded-nonportable-exec.rule.test.cjs new file mode 100644 index 000000000..74758a194 --- /dev/null +++ b/tests/no-unguarded-nonportable-exec.rule.test.cjs @@ -0,0 +1,300 @@ +'use strict'; + +/** + * no-unguarded-nonportable-exec.rule.test.cjs + * + * RuleTester unit tests for the local/no-unguarded-nonportable-exec ESLint rule. + * + * Rule: at Program:exit, if the file contains any chmod call with an exec-bit + * octal (0oNNN & 0o111 !== 0), report each sh/bash -c invocation + * (execFileSync/spawnSync/spawn/exec/execSync) that is NOT inside a Windows + * platform guard. + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + * + * Test cases mirror those in the retired regex-based script + * scripts/lint-windows-test-portability.cjs. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/no-unguarded-nonportable-exec.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-unguarded-nonportable-exec rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.nonportableExec); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('no-unguarded-nonportable-exec invalid cases', () => { + test('invalid: chmod 0o755 + execFileSync bash -c with no guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + execFileSync('bash', ['-c', 'echo hi']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: chmod 0o111 (pure exec bits) + spawnSync sh -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o111); + spawnSync('sh', ['-c', 'x']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: string-form exec(sh -c) + chmod 0o755', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o755); + exec('sh -c "run.sh"'); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: /bin/bash prefix in array form + chmod exec bit', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o755); + execFileSync('/bin/bash', ['-c', 'run']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation expected) ────────────────────────────────────── + +describe('no-unguarded-nonportable-exec valid cases', () => { + test('valid: chmod 0o755 + execFileSync bash -c with process.platform guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + if (process.platform !== 'win32') { + execFileSync('bash', ['-c', 'echo hi']); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o644 (no exec bit) + bash -c does not trigger', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o644); + execFileSync('bash', ['-c', 'cat file']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + execFileSync(sh, [path]) no -c flag', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + execFileSync('sh', [fixturePath]); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: no chmod at all — bash -c alone is fine', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: `execFileSync('bash', ['-c', 'echo hi']);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o444 (read-only, no exec bits) + bash -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(f, 0o444); + execFileSync('bash', ['-c', 'x']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: early-return windows guard before sh -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(f, 0o755); + if (process.platform === 'win32') return; + execFileSync('sh', ['-c', 'run']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: hoisted isWindows guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + const isWindows = process.platform === 'win32'; + fs.chmodSync(f, 0o755); + if (!isWindows) { + execFileSync('bash', ['-c', 'run']); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + exec("finish -c something") — "sh" in "finish" is not a shell invocation (W1 word-boundary)', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('finish -c something'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + exec("publish -c") — "sh" in "publish" is not a shell invocation (W1 word-boundary)', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('publish -c'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C1 fix: '-c' must be FIRST element; a script receiving '-c' as a later arg + // is not a shell -c invocation. + test('valid (C1): chmod 0o755 + execFileSync(sh, [fixturePath, "-c"]) — script arg, not shell -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixturePath, 0o755); + execFileSync('sh', [fixturePath, '-c']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C2 fix: 'sh -c' embedded mid-string in data (as arg to printf) must NOT flag. + test('valid (C2): chmod 0o755 + exec("printf \\"sh -c\\"") — sh -c as data, not a shell invocation', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('printf "sh -c"'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C2 fix: 'sh -c' embedded after other words must NOT flag. + test('valid (C2): chmod 0o755 + exec("echo run sh -c later") — sh -c mid-string as data', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('echo run sh -c later'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/portability-rule-disable-ban.test.cjs b/tests/portability-rule-disable-ban.test.cjs index da75af616..66a40798a 100644 --- a/tests/portability-rule-disable-ban.test.cjs +++ b/tests/portability-rule-disable-ban.test.cjs @@ -32,7 +32,7 @@ const { globSync } = require('glob'); const PROTECTED_RULES = [ 'no-path-literal-in-assert', 'no-posix-mode-bit-assert', - // Future phases: add new local/ portability rules here. + 'no-unguarded-nonportable-exec', ]; // ── Detect disable directives via the comment text ───────────────────────────