`cmdVerifyKeyLinks` compiled `must_haves.key_links[].pattern` from plan frontmatter with `new RegExp()` and tested it against whole file contents, so a nested-quantifier pattern such as `(a+)+$` hung `verify-phase` indefinitely (CWE-1333). JavaScript has no regex-execution timeout.
Untrusted patterns now run on RE2 (re2js), whose match time is linear in input length — the class is closed by the engine, not by a heuristic screen. The screen lost in the ADR-0174 consolidation was deliberately NOT restored: it never worked, since `(a|a)*$`, `((a+))+$`, `(a+){2,}$` and `(a{1,3})+$` all evade it. A refused pattern's matcher returns false for every input, so it cannot report a match no matter what the caller does.
The engine is vendored at gsd-core/bin/lib/vendor/re2js.cjs because gsd-core/bin/** is copied into installed trees with no node_modules; runtime dependencies are unchanged. New ESLint rule local/no-external-require-in-bin enforces that invariant, which had been documented in a comment since the #3024/#2071 bug class and enforced nowhere.
Backreferences and look-around are unsupported by RE2 by construction — disclosed in a Changed changeset.
Closes #3477
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
129
eslint-rules/no-external-require-in-bin.cjs
Normal file
129
eslint-rules/no-external-require-in-bin.cjs
Normal file
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-external-require-in-bin
|
||||
*
|
||||
* Flag any `require(...)` / `import ... from '...'` under `gsd-core/bin/**`
|
||||
* whose specifier is neither relative (`./`, `../`) nor a Node builtin
|
||||
* (including the `node:` prefix form).
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* `gsd-core/bin/**` is copied by the installer into trees that have NO
|
||||
* `node_modules` (e.g. `~/.claude/gsd-core/`). An external (npm-package)
|
||||
* `require()`/`import` under this tree resolves fine in THIS repo (where
|
||||
* `node_modules/` exists) but throws `Cannot find module '<pkg>'` for every
|
||||
* installed user, because the package is never shipped there. #3477
|
||||
* follow-up: `src/pattern.cts` (compiled to `gsd-core/bin/lib/pattern.cjs`)
|
||||
* shipped `import { RE2JS } from 're2js'` and broke `verify` for every
|
||||
* installed user until the dependency was vendored under
|
||||
* `gsd-core/bin/lib/vendor/`.
|
||||
*
|
||||
* The fix for a genuine external-package dependency is never "add it back to
|
||||
* `dependencies`" — vendor the compiled artifact under
|
||||
* `gsd-core/bin/lib/vendor/` (a verbatim, third-party copy; see
|
||||
* `gsd-core/bin/lib/vendor/README.md`) and import it via a relative path
|
||||
* instead.
|
||||
*
|
||||
* ## Why this is ALSO registered on src/**\/*.cts
|
||||
*
|
||||
* Every `src/**\/*.cts` module compiles 1:1 into `gsd-core/bin/lib/*.cjs`
|
||||
* (ADR-457; `tsconfig.build.json` `rootDir: "src"`, `outDir:
|
||||
* "gsd-core/bin/lib"`), and the emitted `.cjs` mirrors are almost entirely
|
||||
* `eslint.config.mjs` global-`ignores`d as generated artifacts (lint the
|
||||
* source, not the tsc output) — so a rule registered ONLY on
|
||||
* `gsd-core/bin/**\/*.cjs` would never see a bad import re-introduced into an
|
||||
* already-migrated module. `src/pattern.cts`'s `import { RE2JS } from
|
||||
* 're2js'` is exactly this case: the compiled `gsd-core/bin/lib/pattern.cjs`
|
||||
* is on the ignore list, so only catching it at the `.cts` source closes the
|
||||
* gap. `TSImportEqualsDeclaration` (the `import x = require('./y.cjs')` form
|
||||
* used throughout `src/**\/*.cts` for CommonJS interop) is handled alongside
|
||||
* plain `ImportDeclaration` for this reason.
|
||||
*/
|
||||
|
||||
const { builtinModules } = require('node:module');
|
||||
|
||||
const BUILTIN_MODULES = new Set(builtinModules);
|
||||
|
||||
/**
|
||||
* Is `specifier` a Node builtin module (with or without the `node:` prefix)?
|
||||
* @param {string} specifier
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBuiltinModule(specifier) {
|
||||
const bare = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
|
||||
return BUILTIN_MODULES.has(bare) || BUILTIN_MODULES.has(specifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `specifier` a relative import (`./` or `../`)?
|
||||
* @param {string} specifier
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRelativeSpecifier(specifier) {
|
||||
return specifier.startsWith('./') || specifier.startsWith('../');
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow require()/import of an external (non-relative, non-builtin) module under gsd-core/bin/** (installed trees have no node_modules)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
externalRequireInBin:
|
||||
'External module "{{specifier}}" required/imported under gsd-core/bin/**: installed ' +
|
||||
'trees have no node_modules (gsd-core/bin/** is copied verbatim into e.g. ' +
|
||||
'~/.claude/gsd-core/), so this resolves here but throws "Cannot find module' +
|
||||
'" for every installed user. Vendor the artifact under gsd-core/bin/lib/vendor/ ' +
|
||||
'(see gsd-core/bin/lib/vendor/README.md) and import it via a relative path instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/**
|
||||
* @param {import('eslint').Rule.Node} node — reported node
|
||||
* @param {string} specifier
|
||||
*/
|
||||
function check(node, specifier) {
|
||||
if (typeof specifier !== 'string') return;
|
||||
if (isRelativeSpecifier(specifier)) return;
|
||||
if (isBuiltinModule(specifier)) return;
|
||||
context.report({ node, messageId: 'externalRequireInBin', data: { specifier } });
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') return;
|
||||
const arg = node.arguments[0];
|
||||
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return;
|
||||
check(node, arg.value);
|
||||
},
|
||||
ImportDeclaration(node) {
|
||||
check(node, node.source.value);
|
||||
},
|
||||
ImportExpression(node) {
|
||||
const arg = node.source;
|
||||
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return;
|
||||
check(node, arg.value);
|
||||
},
|
||||
// `import foo = require('...')` — the CommonJS-interop form used
|
||||
// throughout src/**/*.cts (every src/*.cts compiles 1:1 into
|
||||
// gsd-core/bin/lib/*.cjs, so it is exactly as much "gsd-core/bin/**"
|
||||
// content as a hand-written .cjs file is).
|
||||
TSImportEqualsDeclaration(node) {
|
||||
const ref = node.moduleReference;
|
||||
if (!ref || ref.type !== 'TSExternalModuleReference') return;
|
||||
const arg = ref.expression;
|
||||
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return;
|
||||
check(node, arg.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
Reference in New Issue
Block a user