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>
311 lines
13 KiB
JavaScript
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);
|