Files
msd-core/eslint-rules/lib/portability-vocab.cjs
Tom Boucher 2972da4c9d enhance(#3619): ratchet the platform seam with local/no-private-binary-resolution (epic #3411 Phase 3) (#3636)
* chore(#3619): ratchet the platform seam with local/no-private-binary-resolution

Epic #3411 Phase 3, the ratchet. Scope revised with maintainer approval and
recorded on the issue: the epic's literal ask was a rule rejecting a bare-name
spawn outside the seam. Surveyed at ac1b6d679, ~30 such sites exist and none is
a defect — git, gh and npm ship native .exe that CreateProcess resolves unaided,
and the rest are POSIX-only tools. ADR-1703 rules 2 and 3 forbid grandfathering
and escape hatches, so a literal rule would be unsuppressable and would force
rewriting 30 correct calls.

The epic's actual thesis was four private RESOLVERS, not four bare spawns. So
the rule flags re-implementing resolution: reading PATHEXT in any casing from
any object, and a hardcoded list carrying two or more of .exe/.cmd/.bat/.com —
precisely the shapes fallow-runner's candidateNames and gsd-tools' PATHEXT
string had before Phases 1 and 2 deleted them.

Three boundaries were arrived at rather than assumed:

  two-or-more   a single .endsWith('.cmd') is a classification, not a candidate
                set; runtime-hooks-surface derives .cmd shim paths that way
  boundary-aware  a naive substring test flags .execute and .compacting, caught
                on src/host-integration.cts before it could become a false
                positive nobody could suppress
  suffix-anchored  the seam exemption matches src/shell-command-projection.cts
                exactly; a substring match would also exempt the dispatch test
                file. Case I9 pins it.

PATH scans are deliberately NOT flagged — membership checks (bin/install.js)
are indistinguishable from resolution scans, and an unsound rule in a
zero-escape-hatch architecture is worse than no rule.

To make the ratchet strict with no carve-out, resolveExecutableBinary gained
pathOverride: search THIS PATH, read everything else including PATHEXT from the
ambient environment. resolveFallowBinary now supplies its own search path
without hand-threading PATHEXT, which would itself have been a private read.
The three alternatives were all worse: exempting the file is grandfathering,
exempting the AST shape is a carve-out every future caller must replicate, and
dropping the pass-through would silently ignore a user's real PATHEXT — buying
a lint rule with a correctness regression.

eslint-rules/** is outside the rule's globs rather than exempted, because
portability-vocab.cjs owns the extension set. scripts/**/*.cjs got its own block
so that exclusion does not leave a hole in the ratchet.

Started green with nothing suppressed. Proven able to fail: a fixture with both
signals reports two errors.

Refs #3411

* fix(#3619): close the PATHEXT destructuring evasion and correct two overclaims

Adversarial review found a trivial evasion of the rule's primary signal: the
visitor only handled MemberExpression, so

  const { PATHEXT } = process.env
  const { PATHEXT: exts } = process.env
  const { Pathext } = opts.env

were all unflagged. That is a common idiom, not an exotic bypass. An ObjectPattern
visitor now catches it in every form — renamed, any casing, any receiver, string
keys — while leaving a computed key alone, since it is not statically decidable.
I10-I13 pin the invalid forms and V9/V10 pin PATH and the computed key.

Two overclaims corrected, both mine:

Standards review proved the docs were factually wrong. Both the ADR amendment and
the CONTEXT.md entry asserted that tests/shell-command-projection-dispatch.test.cjs
is still linted by this rule. It is not — the rule's surface is src, gsd-core/bin,
scripts and hooks, and tests/** is deliberately outside it because test setup
legitimately assigns process.env.PATHEXT (fallow-runner's P3 does exactly that).
The suffix-vs-substring distinction is therefore proven by RuleTester case I9
feeding a synthetic filename, NOT by real coverage of that file. Both documents now
say so.

The rule's own docstring claimed the seam exemption matches the seam path
'exactly'. It is a suffix match, so a nested foo/src/shell-command-projection.cts
would also be exempt. Suffix matching is kept — it is how sibling rules resolve
paths and the nested case does not exist — but the docstring now states the
boundary rather than overstating the precision.

The evasion fix was verified by executing eslint against both destructuring forms
in scripts/, not by inspection. Probe: 31/31.

Refs #3411

* chore(#3619): backfill changeset pr number 3636

---------

Co-authored-by: sim <sim@local>
2026-08-18 16:40:17 -04:00

411 lines
15 KiB
JavaScript

'use strict';
/**
* portability-vocab.cjs — single source of truth for path-related portability.
*
* PATH_RETURNING_FNS: canonical list of function calls (Node builtins and
* project resolvers) that return a filesystem path. The drift-guard test
* (tests/portability-vocab-drift.test.cjs) enforces completeness against
* src/runtime-homes.cts's exported path-returning functions.
*
* EXTEND THIS LIST when adding a new path resolver to the codebase.
* The drift-guard test will fail if you forget.
*
* ── Known boundaries ─────────────────────────────────────────────────────────
*
* Matching is by spelling: `path`, `os`, and the project resolver names below
* are assumed to refer to the standard Node modules / project resolver exports.
* A local variable that shadows one of these names (e.g. `const path = …`) is
* out of scope — the helpers treat it as the real module.
*
* isPosixNormalizerCall inspects only the DIRECT argument of the call node;
* deeper nesting (e.g. `String(path.join(...)).toLowerCase().replace(/\\/g,'/')`)
* is not covered — only the outermost call and one level of String() cast are
* visible to the rule.
*
* countWindowsExecutableExtensions matches by spelling (case-insensitive)
* against the fixed WINDOWS_EXECUTABLE_EXTENSIONS set below — it does not
* parse the string as a PATHEXT-style delimited list, so it also matches a
* bare substring occurrence (e.g. `.exe` inside `.exeFoo`). That is
* deliberate: the rule this feeds (local/no-private-binary-resolution) only
* needs "does this string carry two-or-more of these extensions", the same
* shape both deleted private resolvers had, not a strict grammar.
*/
/**
* Canonical set of function names (dotted or bare) that return filesystem paths.
*
* Format:
* - "path.join" → MemberExpression: object=Identifier{path}, property=Identifier{join}
* - "os.homedir" → MemberExpression: object=Identifier{os}, property=Identifier{homedir}
* - "getGlobalDir" → Identifier callee with that name
*/
const PATH_RETURNING_FNS = [
// ── Node built-ins ──────────────────────────────────────────────────────────
'path.join',
'path.resolve',
'path.dirname',
'path.basename',
'path.normalize',
'path.relative',
'os.homedir',
'os.tmpdir',
// ── Project resolvers (src/runtime-homes.cts exports + install.js helpers) ──
// Add bare function names here; dotted forms (e.g. obj.resolveX) are not used
// in the test corpus because these are module-level exports, not methods.
'resolveAgentDir',
'getGlobalConfigDir',
'getGlobalSkillsBase',
// #2088 (ADR-1239 upgrade 3): resolves the on-disk skills-install dir honoring
// a skills-kind `home` override (e.g. Codex → $HOME/.agents/skills).
'_resolveSkillsRootDir',
'getGlobalSkillDir',
'getGlobalSkillDisplayPath',
'resolveSkillsBaseFromDescriptor',
'resolveConfigHomeFromDescriptor',
'resolveKimiGlobalDir',
// #2095 (EoS/kimi): resolves the directory holding Kimi CLI's OWN native
// config.toml (~/.kimi by default, KIMI_SHARE_DIR override) — a sibling of,
// and deliberately separate from, resolveKimiGlobalDir's generic
// Agent-Skills root above.
'resolveKimiHooksTomlDir',
'resolveAntigravityGlobalDir',
'getGlobalDir',
'getConfigDirFromHome',
'resolveKiloConfigPath',
'resolveOpencodeConfigPath',
'computePathPrefix',
'expandHome',
'getPathX',
'normalizeInstallRelativePath',
'toPosixPath',
];
/**
* Windows executable extensions (lowercase canonical). This is the fixed
* candidate-extension set both deleted private resolvers (`fallow-runner`'s
* `['fallow.exe','fallow.cmd','fallow.bat','fallow']` and `gsd-tools`'
* `'.EXE;.CMD;.BAT;.COM'`) hardcoded; local/no-private-binary-resolution
* flags a re-occurrence of two-or-more of these outside the seam.
*/
const WINDOWS_EXECUTABLE_EXTENSIONS = ['.exe', '.cmd', '.bat', '.com'];
/**
* The Windows PATHEXT environment variable name. Windows environment variable
* names are case-insensitive, so a read of ANY casing of this name (`Pathext`,
* `pathext`, ...) is the signal local/no-private-binary-resolution matches on.
*/
const PATHEXT_VAR_NAME = 'PATHEXT';
/**
* Boundary-aware match for each WINDOWS_EXECUTABLE_EXTENSIONS entry, in the
* same order. A plain substring test misfires on ordinary prose/event-name
* tokens that merely start with the same three letters — e.g. `.execute`
* contains the substring `.exe`, and `.compacting` contains `.com`, neither
* of which is a Windows executable extension (a real false positive this
* caught in src/host-integration.cts's OPENCODE_EXTENSION_EVENTS list). The
* negative lookahead requires the match NOT be immediately followed by
* another letter, so `.exe` at the end of a token or before a delimiter
* (`;`, `.`, `'`, whitespace, end-of-string) still matches, but `.exe` inside
* `.execute` does not. Hand-written RegExp literals (not built from a
* runtime string) so this itself is not an adhoc-regex-escape construction.
*/
const WINDOWS_EXECUTABLE_EXTENSION_PATTERNS = [
/\.exe(?![a-zA-Z])/i,
/\.cmd(?![a-zA-Z])/i,
/\.bat(?![a-zA-Z])/i,
/\.com(?![a-zA-Z])/i,
];
/**
* Returns how many DISTINCT entries of WINDOWS_EXECUTABLE_EXTENSIONS appear
* (case-insensitively, boundary-aware) in `str`. Used by
* local/no-private-binary-resolution to apply its two-or-more threshold —
* see the "Known boundaries" note above for the matching semantics.
*
* @param {string} str
* @returns {number}
*/
function countWindowsExecutableExtensions(str) {
if (typeof str !== 'string' || str.length === 0) return 0;
let count = 0;
for (const pattern of WINDOWS_EXECUTABLE_EXTENSION_PATTERNS) {
if (pattern.test(str)) count += 1;
}
return count;
}
/**
* Returns true when `node` is a CallExpression whose callee matches one of the
* PATH_RETURNING_FNS entries.
*
* Handles two call shapes:
* - Dotted: path.join(…) → callee is MemberExpression{object: Identifier, property: Identifier}
* - Bare: getGlobalDir() → callee is Identifier
*
* @param {import('eslint').Rule.Node} node - AST node to inspect
* @returns {boolean}
*/
function isPathReturningCall(node) {
if (!node || node.type !== 'CallExpression') return false;
const callee = node.callee;
// Dotted call: path.join, os.homedir, etc.
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.property.type === 'Identifier'
) {
const dotted = `${callee.object.name}.${callee.property.name}`;
if (PATH_RETURNING_FNS.includes(dotted)) return true;
}
// Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc.
if (callee.type === 'Identifier') {
if (PATH_RETURNING_FNS.includes(callee.name)) return true;
}
return false;
}
/**
* Returns true when `node` is a string literal (or a template literal with no
* expressions) whose value contains '/' and does NOT look like a URL.
*
* URL exclusion: value starts with 'http://' or 'https://'.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isPosixSlashStringLiteral(node) {
if (!node) return false;
// Plain string literal
if (node.type === 'Literal' && typeof node.value === 'string') {
const v = node.value;
if (!v.includes('/')) return false;
if (v.startsWith('http://') || v.startsWith('https://')) return false;
return true;
}
// Template literal with no expressions (static): `some/path`
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
const v = node.quasis[0]?.value?.cooked ?? '';
if (!v.includes('/')) return false;
if (v.startsWith('http://') || v.startsWith('https://')) return false;
return true;
}
return false;
}
/**
* Returns true when `node` is a CallExpression that normalizes its first
* argument to POSIX-style slashes.
*
* Recognized shapes:
* 1. <x>.replace(/\\/g, '/') — regex /\\/g with replacement '/'
* 2. <x>.replace(/[\\/]/g, '/') — regex /[\\/]/g with replacement '/'
* 3. <x>.replaceAll('\\', '/') — literal backslash to slash
* 4. <x>.replaceAll(path.sep, '/') — path.sep to slash
* 5. <x>.split(path.sep).join('/') — split-join idiom
* 6. toPosixPath(<x>) — explicit wrapper
*
* Note: for replace(), we REQUIRE the 'g' flag on the regex AND the regex
* source must actually target backslashes (source `\\` or `[\\/]`).
* A regex like /foo/g or /\//g does NOT qualify.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isPosixNormalizerCall(node) {
if (!node || node.type !== 'CallExpression') return false;
const callee = node.callee;
// toPosixPath(<x>)
if (callee.type === 'Identifier' && callee.name === 'toPosixPath') return true;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier'
) {
const method = callee.property.name;
const args = node.arguments;
// <x>.replace(regex, '/')
// REQUIRE: g flag + regex source must target backslashes: `\\` or `[\\/]`
if (method === 'replace' && args.length >= 2) {
const regexArg = args[0];
const replacementArg = args[1];
if (
regexArg.type === 'Literal' &&
regexArg.regex != null &&
regexArg.regex.flags.includes('g') &&
_isBackslashTargetingRegex(regexArg.regex.pattern) &&
_isSlashReplacement(replacementArg)
) {
return true;
}
}
// <x>.replaceAll(sep, '/')
if (method === 'replaceAll' && args.length >= 2) {
const sepArg = args[0];
const replacementArg = args[1];
if (_isSlashReplacement(replacementArg)) {
// replaceAll('\\', '/') or replaceAll('\\\\', '/') or replaceAll(path.sep, '/')
if (_isBackslashLiteral(sepArg)) return true;
if (_isPathSep(sepArg)) return true;
}
}
// <x>.split(path.sep).join('/')
// The callee is <split_result>.join — check the object for .split(path.sep)
if (method === 'join' && args.length >= 1 && _isSlashReplacement(args[0])) {
const splitCall = callee.object;
if (
splitCall.type === 'CallExpression' &&
splitCall.callee.type === 'MemberExpression' &&
!splitCall.callee.computed &&
splitCall.callee.property.type === 'Identifier' &&
splitCall.callee.property.name === 'split' &&
splitCall.arguments.length >= 1 &&
_isPathSep(splitCall.arguments[0])
) {
return true;
}
}
}
return false;
}
/** True if node is the replacement '/' string literal */
function _isSlashReplacement(node) {
return node && node.type === 'Literal' && node.value === '/';
}
/**
* True if regexPattern (the raw regex source string, as stored in the AST's
* `.regex.pattern` field) actually targets backslashes.
*
* Accepted: exactly `\\` (two-char, two backslashes: matches one backslash)
* exactly `[\\/]` (five-char: backslash-or-forward-slash charset)
* Rejected: `foo`, `\/` (forward-slash only), anything else.
*
* @param {string} pattern — the AST `.regex.pattern` string
* @returns {boolean}
*/
function _isBackslashTargetingRegex(pattern) {
// Pattern `\\` (two backslash chars in the regex) — matches a single backslash
if (pattern === '\\\\') return true;
// Pattern `[\\/]` (backslash-or-forward-slash charset) — five chars
if (pattern === '[\\\\/]') return true;
return false;
}
/** True if node is a backslash literal ('\\' or '\\\\') */
function _isBackslashLiteral(node) {
if (!node || node.type !== 'Literal') return false;
return node.value === '\\' || node.value === '\\\\';
}
/** True if node is path.sep */
function _isPathSep(node) {
return (
node &&
node.type === 'MemberExpression' &&
!node.computed &&
node.object.type === 'Identifier' &&
node.object.name === 'path' &&
node.property.type === 'Identifier' &&
node.property.name === 'sep'
);
}
/**
* If `node` is `String(<x>)`, return `<x>`; otherwise return `node` as-is.
* Allows the rule to see through String() casts on path expressions.
*
* @param {import('eslint').Rule.Node} node
* @returns {import('eslint').Rule.Node}
*/
function unwrapString(node) {
if (
node &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'String' &&
node.arguments.length === 1
) {
return node.arguments[0];
}
return node;
}
/**
* If `node` is a method-call chain of the form `<receiver>.replace(...)`,
* `<receiver>.replaceAll(...)`, or `<receiver>.split(...).join(...)` that is
* NOT a valid POSIX normalizer (i.e. `isPosixNormalizerCall(node)` is false),
* return the receiver (the `.object` of the callee MemberExpression).
*
* This lets the rule detect:
* `path.join(a,b).replace(/foo/g, '/')` → not a normalizer, but the
* receiver `path.join(a,b)` IS a path-returning call → violation.
*
* Only peels ONE layer. The caller is responsible for checking the peeled node.
* Returns `null` when `node` is already a valid normalizer or is not a method chain.
*
* @param {import('eslint').Rule.Node} node
* @returns {import('eslint').Rule.Node | null}
*/
function unwrapNonNormalizerMethodChain(node) {
if (!node || node.type !== 'CallExpression') return null;
// If it IS a valid normalizer, do NOT peel — the caller already handled that.
if (isPosixNormalizerCall(node)) return null;
const callee = node.callee;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier'
) {
const method = callee.property.name;
// String-mutation methods that commonly wrap path calls
if (method === 'replace' || method === 'replaceAll') {
return callee.object;
}
// <x>.split(...).join(...) — callee.object is the .split() result;
// peel to the .split()'s receiver
if (method === 'join') {
const splitCall = callee.object;
if (
splitCall &&
splitCall.type === 'CallExpression' &&
splitCall.callee.type === 'MemberExpression' &&
!splitCall.callee.computed &&
splitCall.callee.property.type === 'Identifier' &&
splitCall.callee.property.name === 'split'
) {
return splitCall.callee.object;
}
}
}
return null;
}
module.exports = {
PATH_RETURNING_FNS,
WINDOWS_EXECUTABLE_EXTENSIONS,
PATHEXT_VAR_NAME,
countWindowsExecutableExtensions,
isPathReturningCall,
isPosixSlashStringLiteral,
isPosixNormalizerCall,
unwrapString,
unwrapNonNormalizerMethodChain,
};