fix(#3477): run untrusted key_links patterns on a linear-time engine (#3496)

`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:
Tom Boucher
2026-08-14 14:34:36 -04:00
committed by GitHub
parent b946051a46
commit 895d9df96d
19 changed files with 9067 additions and 17 deletions

View 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;