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>
This commit is contained in:
@@ -30,6 +30,7 @@ import noUnboundedSpawn from './eslint-rules/no-unbounded-spawn.cjs';
|
||||
import noDuplicateFoldMarker from './eslint-rules/no-duplicate-fold-marker.cjs';
|
||||
import requireSubprocessTimeout from './eslint-rules/require-subprocess-timeout.cjs';
|
||||
import noExternalRequireInBin from './eslint-rules/no-external-require-in-bin.cjs';
|
||||
import noPrivateBinaryResolution from './eslint-rules/no-private-binary-resolution.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -54,6 +55,7 @@ const localPlugin = {
|
||||
'no-duplicate-fold-marker': noDuplicateFoldMarker,
|
||||
'require-subprocess-timeout': requireSubprocessTimeout,
|
||||
'no-external-require-in-bin': noExternalRequireInBin,
|
||||
'no-private-binary-resolution': noPrivateBinaryResolution,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -376,6 +378,11 @@ export default tseslint.config(
|
||||
// place a bad external import in an already-migrated module is still
|
||||
// visible to lint.
|
||||
'local/no-external-require-in-bin': 'error',
|
||||
// #3619 (epic #3411 Phase 3): flag a re-implemented Windows binary
|
||||
// resolver — a PATHEXT read or a hardcoded exe-extension list — outside
|
||||
// the platform seam (src/shell-command-projection.cts, exempt by path).
|
||||
// See .gsd/phase/chore-3619-no-bare-binary-spawn/40-design.md.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -487,6 +494,35 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
'local/no-external-require-in-bin': 'error',
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// ── scripts/**/*.cjs only — no-private-binary-resolution ───────────────────
|
||||
// A NARROWER block than the combined CommonJS glob above, on purpose:
|
||||
// eslint-rules/** is deliberately OUTSIDE this rule's surface, because
|
||||
// eslint-rules/lib/portability-vocab.cjs is the single source of truth for
|
||||
// the Windows executable-extension set (ADR-1703 rule 4) — its own
|
||||
// WINDOWS_EXECUTABLE_EXTENSIONS vocabulary array would trip the rule it
|
||||
// feeds. Registering on the shared `gsd-core/bin/**/*.cjs + scripts/**/*.cjs
|
||||
// + eslint-rules/**/*.cjs + ...` block would flag that file; this block
|
||||
// covers scripts/**/*.cjs only, so the rule still lints every other script
|
||||
// in the tree without the vocabulary file self-flagging.
|
||||
{
|
||||
files: ['scripts/**/*.cjs'],
|
||||
plugins: {
|
||||
local: localPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -505,6 +541,8 @@ export default tseslint.config(
|
||||
'n/no-path-concat': 'error',
|
||||
// ADR-3212 Phase 1 (#3412): pattern-construction seam prohibition.
|
||||
'local/no-adhoc-regex-escape': 'error',
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
// n/no-process-exit is deliberately OFF for hooks ONLY.
|
||||
//
|
||||
// A hook is a standalone process whose ENTIRE contract is its exit code: the
|
||||
|
||||
Reference in New Issue
Block a user