Files
msd-core/scripts/check-glossary-refs.cjs
sim 6f0e5ccf85 fix(#4636,#4653): close the symlink hole, revert a wrong collapse, fix six review findings
The RED checkpoint and two orthogonal reviews found eight defects. All fixed here.

THE COLLAPSE THAT WAS WRONG — installer-migrations. Routing ensureInsideConfig's
containment decision through the realpath-based canonical predicate broke four
tests, and the failure message says it plainly: "migration path escapes
configDir: extensions/gsd.cjs". That module's entire contract is that a
symlinked managed path is snapshotted, restored and backed up AS A LINK and
never dereferenced. The canonical predicate dereferences, then rejects the
result for escaping configDir — so it destroys exactly the thing the module
exists to preserve. Reverted to lexical, with the ruling recorded above the
function so it is not collapsed a third time. normalizeRelPath is the real
pre-gate there; it throws on absolute paths and '..' before this check runs.

That makes THREE deliberately-retained implementations, not two, and they share
one shape worth naming: a realpath-based predicate is the wrong tool wherever a
symlink must be PRESERVED rather than resolved. CONTEXT.md and
docs/explanation/security-model.md are corrected — both previously described
ensureInsideConfig as collapsed.

THE MISSED CONSUMER. tests/security-prompt-injection.security.test.cjs
destructures validatePath from the compiled lib; un-exporting it turned five
tests into TypeError. It appeared in my own earlier search output and I did not
follow it up. Translated under the same rule as the rest: assertions on the
rejection REASON go through assertWithinRoot, boolean-only through
tryWithinRoot.

VALIDATE-ONE-PATH-USE-ANOTHER, FOUND TWICE MORE. This is the fourth and fifth
occurrence in this epic of the exact defect it exists to prevent.
  - scripts/check-glossary-refs.cjs decided containment on `token` and then
    stat'd a separately re-joined path.join(ROOT, token). The ContainedPath is
    now carried through to the probe, so the validated value is the probed one.
  - src/init.cts computed skillPathContained and DISCARDED it, re-joining from
    the raw input for the existsSync and read. The branded type exists to make
    that a type error and here it was inert.

AND THE OVER-CORRECTION OF THAT FIX, caught before it shipped. The first attempt
also substituted the validated value into the EMITTED `ref` for a global skill.
That value is a display token, not a path anything reads through — the only fs
access in that branch runs on the lexical path beforehand — so substituting it
changed emitted output two ways: it is realpath-resolved, so a symlinked global
skills directory would have emitted its resolved target instead of the user's
own path, and it came from path.join, so Windows would have emitted a backslash
where the template has a literal '/'. Restored, with the distinction recorded:
the containment check there is a GATE, not a path producer.

A TEST THAT COULD NOT FAIL. The first symlink regression planted its symlink
from inside a hooked fs.readdirSync and never asserted the planting happened —
if the hook did not fire, the "nothing was written outside" assertion passed
trivially, green against vulnerable code. It now asserts the plant, matching its
sibling. The other two were re-checked: one already asserted its equivalent, the
other plants synchronously and cannot silently no-op.

THE SYMLINK FIX ITSELF, now that the tests are proven red on the matrix.
isPathConfined is lexical by design and structurally cannot see a symlink; three
callers relied on it with no defense of their own. install-engine.cts:1608 and
install-profiles.cts:880 refuse to mkdir/write through a link — mkdirSync with
recursive:true does NOT throw on an existing symlink-to-directory, so a planted
link redirected the SKILL.md write outside the install root.
install-profiles.cts:755 refuses to read through one — statSync FOLLOWS links,
so an outside file's contents were returned and installed as a skill body. Each
mirrors the guard retired-artifact-cleanup.cts:77 already uses.

Severity stated accurately rather than dramatically: only the read at :755 needs
no race. _removeGsdEntries sweeps a pre-planted link at :1608 before the write
loop, and :880's stageDir is a fresh mkdtemp, so both of those require winning a
window. They are fixed as defense-in-depth, not as live exploits.

ALSO: the Changed changeset claimed "every command's observable behavior [is]
unchanged". Three rejection messages are reworded. It now says so, and says that
none of them reveals a host path it previously hid. A stale comment in
verify.cts still named validatePath; an init.cts warning hardcoded "resolves
outside the project directory" for a check that also rejects absolute paths, NUL
bytes and empty strings; and the rationale deleted with check-glossary-refs'
retired helper is restored, noting honestly that a rejected token is now
realpath-resolved before rejection rather than rejected by string comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:59:52 -04:00

311 lines
13 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Verifies the machine-checkable claims in CONTEXT.md against the shipped
* tree, so the glossary cannot silently re-rot the way the ADR index did
* before scripts/gen-adr-index.cjs (#2340).
*
* Unlike gen-adr-index.cjs this gate has NO `--write` — CONTEXT.md's prose is
* hand-authored, not a derived artifact this tool can regenerate. It only
* verifies.
*
* Two checks:
*
* A. File references resolve. Every backticked token in CONTEXT.md that
* looks like a file path — and whose path is inside a TRACKED_PREFIXES
* directory (or is one of the two named exact files) — must exist on
* disk. Everything else (generated bin/lib/*.cjs, `.planning/` runtime
* artifacts, `~/`- or `/`-rooted paths, bare filenames, example data
* shapes like `capability.json`) is deliberately ignored: asserting
* those would false-fail a clean checkout, which is the exact trap this
* gate exists to avoid falling into itself.
*
* B. `allRuntimes` enum parity. CONTEXT.md documents the runtime enum's
* count and member list in prose (e.g. "Runtime enum: `allRuntimes` (17
* values: claude, ...)"). This is compared against the real
* `allRuntimes` array literal in bin/install.js — both the count and the
* member set — so adding/removing a runtime without updating the prose
* is caught.
*
* Usage:
* node scripts/check-glossary-refs.cjs # print findings to stdout
* node scripts/check-glossary-refs.cjs --check # exit 1 on any finding
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { tryWithinRoot, PathAcceptance } = require('../gsd-core/bin/lib/security.cjs');
const ROOT = path.resolve(__dirname, '..');
const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const INSTALL_JS_PATH = path.join(ROOT, 'bin', 'install.js');
/**
* Directory prefixes this gate can verify against the shipped tree. A token
* outside these — most importantly `gsd-core/bin/lib/**`, which is generated
* and gitignored — is not a claim this gate can check, so it is skipped
* rather than asserted.
*/
const TRACKED_PREFIXES = [
'src/',
'tests/',
'scripts/',
'docs/',
'gsd-core/references/',
'gsd-core/workflows/',
'gsd-core/templates/',
'gsd-core/contexts/',
'.github/',
'eslint-rules/',
];
/** The only bare (no-prefix-match) tokens this gate checks by exact name. */
const TRACKED_EXACT = new Set(['bin/install.js', 'package.json']);
/**
* Paths CONTEXT.md documents whose ABSENCE is the healthy steady state.
*
* `TRACKED_PREFIXES` skips claims this gate *cannot* check. This is the narrower
* third case: a claim it must not check, because "does not exist" is the correct
* state rather than drift.
*
* `tests/emitted-drift-ack.json` is the acknowledgment file from ADR-2719 §3. Its
* whole design property is that it appears in the changed-files list ONLY when
* something rippled unexpectedly — "touching it IS the alarm". A permanently
* committed copy would signal nothing, which is exactly why the ADR rejected
* shipping an empty stub. So the file is absent on a healthy `next` and present
* only inside a PR that needs it, and asserting either way is wrong.
*
* The emitted-attribution family (`tests/fixtures/golden-install-parity`,
* `tests/golden-install-parity.test.cjs`, `scripts/gen-golden-install-parity-zcode.cjs`,
* `tests/agent-size-baseline.json`, `tests/workflow-size-baseline.json`,
* `scripts/git-merge-regen-driver.cjs`, `scripts/update-size-baseline.cjs`) was RETIRED
* by the #2724 cutover — the differential attribution check replaced the committed
* baselines and their generator/bridge tooling. CONTEXT.md's RULESET.EMITTED_ATTRIBUTION
* predicate documents that retirement ("Historically …"), so the mentions are history,
* not live claims, and their absence is exactly the healthy state the retirement
* produced. The whole documented family is listed, not just the members today's tick
* parity happens to hide — the two tooling paths were invisible only because of where
* line 585's inner backticks sat, which is the #2778 luck this gate must not rely on.
*
* `scripts/eslint-rules` appears only inside a CONTRASTIVE mention ("the local
* plugin lives at `eslint-rules/` (repo root, NOT `scripts/eslint-rules/`)") — the
* predicate asserts where the directory is NOT, so non-existence is the claim
* being made, not drift away from one.
*
* Until #2778 this set's first entry passed only by accident: CONTEXT.md's
* `RULESET.` entries are themselves backtick-wrapped and contain backticks, so the
* sequential pairing in `extractTrackedRefs` happened to leave this token outside a
* code span. Any edit that shifted the parity — such as #2778's own — exposed it.
* A gate that passes by luck is not passing; naming the exemption makes the intent
* explicit and survives the next edit. #3604 removed the luck itself (pairing is
* per line), which is what surfaced the entries above: each names a path whose
* absence is deliberate, so each is exempted by name for the same reason.
*/
const INTENTIONALLY_ABSENT = new Set([
'tests/emitted-drift-ack.json',
'tests/fixtures/golden-install-parity',
'tests/golden-install-parity.test.cjs',
'scripts/gen-golden-install-parity-zcode.cjs',
'tests/agent-size-baseline.json',
'tests/workflow-size-baseline.json',
'scripts/git-merge-regen-driver.cjs',
'scripts/update-size-baseline.cjs',
'scripts/eslint-rules',
]);
/**
* Shape a backticked token must have to even be considered a path candidate:
* one or more `/`-separated segments of word/dot/dash characters, with an
* optional trailing `:<line>` suffix. Anything else inside backticks (CLI
* invocations with spaces, function signatures with parens/commas, bare
* identifiers, env vars) is prose, not a path reference.
*/
const PATH_TOKEN_RE = /^[\w.-]+(?:\/[\w.-]+)*(?::\d+)?$/;
/** Whether `token` (line-suffix already stripped) is one this gate checks. */
function isTracked(token) {
if (INTENTIONALLY_ABSENT.has(token)) return false;
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
}
/**
* Every distinct, trackable file-path token referenced in `text`, with any
* trailing `:<line>` suffix stripped.
*
* #3604: pairing is per LINE, not over the whole text. A single whole-text pass
* `[^`]+` crosses newlines, so one odd-backtick line (the `RULESET.*` predicate
* format is backtick-wrapped and its values sometimes contain backticks) shifted
* the pairing of every later line — a tracked token's visibility depended on
* where it sat, which is how a renamed test file stayed invisible for weeks.
*
* The fact-store predicate lines (one `CLASS.subkey=value` fact per line,
* backtick-wrapped as a whole) carry REAL paths in their values; the span itself
* is not path-shaped (spaces, `=`), so a second pass harvests path-shaped tracked
* tokens from inside any predicate-shaped span rather than letting the outer
* wrapper hide them.
*
* Fragment guards: a real path in this repo never ends in `-` or `.` — those are
* remnants of glob/template mentions (`scripts/gen-*.cjs`, `tests/foo.*.test.cjs`)
* split at the `*` — and `NNNN` is the ADR filename template token
* (CONTRIBUTING's "Do not compute a next number locally"), never a real path.
*/
function extractTrackedRefs(text) {
// Maps token -> the ContainedPath tryWithinRoot returned for it. ADR-4650:
// the value that was validated for containment must be the exact value
// that gets probed later — never a path re-derived (e.g. re-joined) from
// the token, which could diverge from what was actually checked.
const tokens = new Map();
const add = (raw) => {
if (!PATH_TOKEN_RE.test(raw)) return;
const token = raw.replace(/:\d+$/, '');
// Fragment guard, on the EMITTED token (after the :line strip, so
// `src/foo-:12` is judged on `src/foo-`): glob/template remnants end in `-`
// or `.`, a real path in this repo never does.
if (!/[A-Za-z0-9_]$/.test(token)) return;
if (token.includes('NNNN')) return;
if (!isTracked(token)) return;
// Containment decision is the canonical predicate's, per ADR-4650. Carry
// the returned ContainedPath forward so checkFileRefs stats the SAME
// value that was validated, instead of re-joining `token` onto ROOT.
//
// The containment ANSWER is unchanged from the retired lexical-only
// `isWithinRoot`, but the canonical predicate resolves symlinks, so a
// rejected token is now realpath-resolved before being rejected rather
// than rejected by string comparison alone; the result is still never
// surfaced and the token is never stat'd unless it is contained.
const contained = tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot);
if (contained === null) return;
tokens.set(token, contained);
};
const subTokenRe = /[\w.-]+(?:\/[\w.-]+)*/g;
for (const line of text.split(/\r?\n/)) {
const re = /`([^`]+)`/g;
let m;
while ((m = re.exec(line)) !== null) {
const span = m[1];
if (PATH_TOKEN_RE.test(span)) {
add(span);
continue;
}
if (!/^[A-Z][A-Za-z0-9_.-]*=/.test(span)) continue;
for (const sub of span.matchAll(subTokenRe)) add(sub[0]);
}
}
return tokens;
}
/** Check A: every tracked reference must resolve on disk. */
function checkFileRefs(contextText) {
const entries = [...extractTrackedRefs(contextText)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const findings = [];
for (const [token, contained] of entries) {
// Stat the ContainedPath returned by tryWithinRoot — NOT a re-joined
// path.join(ROOT, token) — so the path that was validated for
// containment is the path that is probed (ADR-4650).
if (!fs.existsSync(contained)) {
findings.push(`CONTEXT.md references \`${token}\` which does not exist in the repo.`);
}
}
return { findings, checked: entries.length };
}
/** The glossary's own claim: `Runtime enum: `allRuntimes` (N values: a, b, c)`. */
const ALLRUNTIMES_CLAIM_RE = /Runtime enum:\s*`allRuntimes`\s*\((\d+)\s*values:\s*([^)]*)\)/;
function parseClaimedRuntimes(contextText) {
const m = contextText.match(ALLRUNTIMES_CLAIM_RE);
if (!m) return null;
return {
count: Number(m[1]),
members: m[2]
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
}
/** The real `allRuntimes = [...]` array literal in bin/install.js. */
const ALLRUNTIMES_ARRAY_RE = /allRuntimes\s*=\s*\[([^\]]*)\]/;
function parseRealRuntimes(installJsText) {
const m = installJsText.match(ALLRUNTIMES_ARRAY_RE);
if (!m) return null;
return [...m[1].matchAll(/'([^']+)'/g)].map((mm) => mm[1]);
}
/** Check B: CONTEXT.md's prose count + member set must match bin/install.js. */
function checkAllRuntimesParity(contextText, installJsText) {
const claimed = parseClaimedRuntimes(contextText);
if (!claimed) {
return [
'CONTEXT.md is missing the `allRuntimes` enum-count sentence ' +
'("Runtime enum: `allRuntimes` (N values: ...)") that this gate checks against bin/install.js.',
];
}
const real = parseRealRuntimes(installJsText);
if (!real) {
return ['bin/install.js does not contain a parseable `allRuntimes = [...]` array literal.'];
}
const findings = [];
if (claimed.count !== real.length) {
findings.push(
`CONTEXT.md's allRuntimes enum-count sentence claims ${claimed.count} values but bin/install.js's ` +
`allRuntimes array has ${real.length}.`,
);
}
const claimedSet = new Set(claimed.members);
const realSet = new Set(real);
const missingFromProse = real.filter((r) => !claimedSet.has(r)).sort();
const noLongerReal = claimed.members.filter((c) => !realSet.has(c)).sort();
if (missingFromProse.length > 0 || noLongerReal.length > 0) {
const parts = [];
if (missingFromProse.length > 0) parts.push(`missing from CONTEXT.md's list: ${missingFromProse.join(', ')}`);
if (noLongerReal.length > 0) parts.push(`no longer in bin/install.js's allRuntimes: ${noLongerReal.join(', ')}`);
findings.push(`CONTEXT.md's allRuntimes member list has drifted from bin/install.js (${parts.join('; ')}).`);
}
return findings;
}
function main() {
const [, , flag] = process.argv;
const contextText = fs.readFileSync(CONTEXT_PATH, 'utf8');
const installJsText = fs.readFileSync(INSTALL_JS_PATH, 'utf8');
const fileRefs = checkFileRefs(contextText);
const runtimeFindings = checkAllRuntimesParity(contextText, installJsText);
const findings = [...fileRefs.findings, ...runtimeFindings];
if (flag === '--check') {
if (findings.length > 0) {
process.stderr.write(`CONTEXT.md glossary has ${findings.length} drift finding(s).\n\n`);
for (const f of findings) process.stderr.write(` ✗ ${f}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
return;
}
if (findings.length === 0) {
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
} else {
process.stdout.write(`CONTEXT.md glossary has ${findings.length} drift finding(s):\n\n`);
for (const f of findings) process.stdout.write(` ✗ ${f}\n`);
}
}
runMain(main);