feat(#1720): no-unguarded-nonportable-exec AST rule; retire the regex script (Phase 3) (#1723)

Phase 3 of epic #1702. Closes #1720.
This commit is contained in:
Tom Boucher
2026-06-25 16:25:48 -04:00
committed by GitHub
parent cf2e66b39e
commit c57e0d56c2
12 changed files with 616 additions and 332 deletions

View File

@@ -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 <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> 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 <path>) 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 === <that exact octal>; 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`

View File

@@ -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)

View File

@@ -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

View File

@@ -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 <path>` instead of `sh -c <path>`).
*
* 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 <path>` 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 <cmd> → args = ['-c', <cmd>]
// 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;

View File

@@ -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',

View File

@@ -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 <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> 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 <path>) 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
},
{

View File

@@ -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",

View File

@@ -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 <path>`), or annotate
* `// windows-portability-ok: <reason>`.
*
* ## 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 <path>`), or annotate ' +
'`// windows-portability-ok: <reason>`.\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 };
}

View File

@@ -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() {

View File

@@ -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');
});
});

View File

@@ -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: [],
});
});
});

View File

@@ -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 ───────────────────────────