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

@@ -30,6 +30,7 @@ import { execGit, platformReadSync as safeReadFile } from './shell-command-proje
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
import { compileUserPattern, MAX_USER_PATTERN_LEN } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheck = require('./agent-install-check.cjs');
const { checkAgentsInstalled, checkCodexModelPosture } = agentInstallCheck;
@@ -1235,22 +1236,54 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
check['detail'] = 'Source file not found (from: must be a relative file path; describe components/endpoints in via:)';
}
} else if (link['pattern']) {
try {
const regex = new RegExp(link['pattern'] as string);
if (regex.test(sourceContent)) {
check['verified'] = true;
check['detail'] = 'Pattern found in source';
} else {
const targetContent = safeReadFile(path.join(cwd, (link['to'] as string) || ''));
if (targetContent && regex.test(targetContent)) {
check['verified'] = true;
check['detail'] = 'Pattern found in target';
} else {
check['detail'] = `Pattern "${link['pattern'] as string}" not found in source or target`;
}
const pat = compileUserPattern(link['pattern']);
if (pat.neutralized !== null) {
// A neutralized pattern was refused and never compiled — that is NOT
// the check the plan author wrote, so it must never report verified
// regardless of what an unrelated fallback might otherwise have
// matched (#3477 regression: pattern "(" previously neutralized to a
// literal-escaped match that matched nearly any source file,
// producing a false verified: true / all_verified: true). The engine
// itself now guarantees `test()` returns false for a refused
// pattern; this explicit branch exists to produce the good message.
// The match-and-report path below is skipped entirely rather than
// run and then overwritten, which used to leave a misleading
// "Pattern found in source" detail alongside the neutralization note.
check['pattern_neutralized'] = pat.neutralized;
let reason: string;
switch (pat.neutralized) {
case 'empty':
reason = 'pattern is not a usable string — no match attempted';
break;
case 'too-long':
reason = `pattern exceeded ${MAX_USER_PATTERN_LEN} chars — not evaluated`;
break;
case 'unsupported':
reason = 'pattern is not valid RE2 syntax — backreferences and look-around are not supported';
break;
}
check['detail'] = `Pattern not verified (${reason})`;
} else {
try {
if (pat.test(sourceContent)) {
check['verified'] = true;
check['detail'] = 'Pattern found in source';
} else {
const targetContent = safeReadFile(path.join(cwd, (link['to'] as string) || ''));
if (targetContent && pat.test(targetContent)) {
check['verified'] = true;
check['detail'] = 'Pattern found in target';
} else {
check['detail'] = `Pattern "${link['pattern'] as string}" not found in source or target`;
}
}
} catch (err) {
// Report the errno only — never the full error/message, which for a
// re-thrown non-ENOENT platformReadSync failure (e.g. EISDIR from an
// untrusted `to:` like "../..") embeds an absolute filesystem path.
const code = (err as NodeJS.ErrnoException)?.code ?? 'unknown';
check['detail'] = `Pattern check failed: ${code}`;
}
} catch {
check['detail'] = `Invalid regex pattern: ${link['pattern'] as string}`;
}
} else {
if (sourceContent.includes((link['to'] as string) || '')) {