Files
msd-core/src/runtime-identity.cts
sim 5e997de5f0 fix(#3841): honor the payload anchor in the classifier, dedup the fixture
Three review findings, all fixed.

The isolated security review found a SECOND divergence the design missed. The
classifier parses structurally, so it accepted `packageName` at any key
position; the shell's `case` is anchored at the start of stdout. For
`{"note":"x","packageName":"<us>",...}` the classifier said ok and the shell
said unverified -- a fail-open disagreement, and none of the original ten parity
rows caught it because every one put `packageName` first. Certifying agreement
that does not hold would have been worse than shipping no parity suite. The
classifier now honors the anchor on its ok arm, which is what the module already
claimed to do: IDENTITY_RAW_PREFIX is documented as "ANCHORED, never a substring
search". A foreign packageName stays identity_mismatch wherever it appears, so
that arm is untouched. Parity rows P11-P13 added.

The spec review found the test matrix marked the empty-string `packageName` row
as already covered. It was not -- the `.length > 0` guard is a distinct path
from "no packageName key at all", which is tested. Added, and the matrix
corrected to say it was wrong.

The standards review flagged ~45 lines of fixture helpers duplicated verbatim
between the two preamble describes. Extracted to one `makeIdentityFixture()`.

Changeset rewritten: it described only the exit-code half of the diff.

Refs #3841

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:36:59 -04:00

291 lines
13 KiB
TypeScript

/**
* Runtime identity — prove which package's `gsd-tools` actually executed (#3146).
*
* The predecessor package `get-shit-done-cc` publishes a colliding `gsd-tools`
* bin (verified 2026-08-24 against 1.42.3: `bin.gsd-tools -> bin/gsd-sdk.js`),
* and exposes verbs of the same name with different semantics. #3129 is the
* worked example: `phases.clear` archives here and **deletes** there, and the
* output is success-shaped either way, so 43 phase directories went missing
* with no warning.
*
* That collision is now prevented in the resolver rather than detected here:
* the launcher's PATH branch resolves `gsd_run`, which only this package
* publishes and which self-locates to its sibling shim, so a foreign binary is
* unreachable from PATH. The path-based branches — a project-local install, a
* runtime config directory — have no such structural guarantee: they trust
* their configured location. #3841 closes that remaining route by making the
* launcher preamble PROBE the resolved tool once, before any verb runs, and
* warn when it cannot prove it is this package. This module backs both the
* `runtime-identity` verb the preamble calls and the vocabulary that gate
* reports in (`IDENTITY_STATUS`).
*
* The rollout is warn-then-fail (the #3146 maintainer ruling): the preamble
* prints one actionable line and proceeds. It cannot hard-fail yet because
* `no_identity_verb` does not distinguish a foreign package from an
* `@opengsd/gsd-core` older than the verb — and during rollout the old-version
* case is the common one, so a hard failure would stop working installs.
*
* Parsing is deliberately STRICT. The whole value of the check is telling "us"
* from "not us"; a lenient parse that accepts the predecessor's usage text as
* close-enough would reproduce the exact silent success #3129 already produced.
* Liberality is spent on visibility instead — distinct reason codes, each
* naming what actually happened.
*
* The classifier and the shell preamble are held in agreement by a
* cross-surface parity test (#3841): both read stdout only and must reach the
* same verdict for the same payload, or the preamble's warning and this
* module's diagnostic would silently diverge on the same input.
*/
import { packageName } from './package-identity.cjs';
import { readHostVersion } from './capability-loader.cjs';
/** The package name a legitimate GSD runtime reports. Baked at build time (#498). */
export const EXPECTED_PACKAGE_NAME: string = packageName;
/**
* Why an identity probe did or did not verify.
*
* `no_identity_verb` and `unparseable` are kept apart on purpose: the first
* means "something else answered" (a foreign binary that has no such verb), the
* second means "we got an answer we cannot trust". They point at different
* remedies, and collapsing them is what makes a diagnostic useless.
*/
export type IdentityReason =
| 'ok'
| 'identity_mismatch'
| 'no_identity_verb'
| 'unparseable'
| 'probe_failed';
/**
* The two-valued status the launcher preamble exports as `GSD_IDENTITY_STATUS`
* (#3841). Frozen so it is a VALUE a test can assert on: CONTRIBUTING forbids
* proving the gate fired by matching its human-readable warning prose.
*
* Two values, not five: the shell has no use for the distinction between
* `no_identity_verb` and `unparseable` — it either holds proof or it does not.
* The five-way {@link IdentityReason} stays the diagnostic vocabulary;
* {@link statusForVerdict} is the only bridge between them.
*/
export const IDENTITY_STATUS = Object.freeze({
OK: 'ok',
UNVERIFIED: 'unverified',
} as const);
/** A value of {@link IDENTITY_STATUS}. */
export type IdentityStatus = (typeof IDENTITY_STATUS)[keyof typeof IDENTITY_STATUS];
/**
* The exact byte prefix the preamble anchors its `case` pattern to.
*
* ANCHORED, never a substring search. `JSON.stringify` emits keys in insertion
* order and `buildIdentityPayload` inserts `packageName` first, so a genuine
* `--raw` payload always STARTS with this. An unanchored match would verify the
* decoy `{"packageName":"get-shit-done-cc","note":"@opengsd/gsd-core"}`, which
* is exactly the shape a colliding package could publish.
*
* The preamble anchors at BOTH ends: its `case` pattern is this prefix, then
* anything, then a literal `}`, so a truncated payload fails too. That is safe
* for any future additive field — a JSON object's own closing brace is always
* the last character, whatever the last value's type. It also pairs the literal
* `{` for the raw-text brace guards the preamble is inlined into (#3841); the
* pairing is a consequence of the stronger check, not the reason for it.
*/
export const IDENTITY_RAW_PREFIX = `{"packageName":"${EXPECTED_PACKAGE_NAME}"`;
/** Raw result of running `<resolved gsd-tools> runtime-identity`. */
export interface IdentityProbe {
stdout: string;
exitCode: number | null;
/** The child could not be spawned at all (ENOENT, not executable). */
spawnFailed?: boolean;
/** The child exceeded its timeout and was killed. */
timedOut?: boolean;
}
export interface IdentityVerdict {
reason: IdentityReason;
expected: string;
/** The packageName the probe reported, when it reported a usable one. */
actual?: string;
version?: string;
/** Human-readable evidence for the warning text. Never a full dump. */
detail?: string;
}
/** The documented payload shape. Minimal on purpose — see Hyrum's Law note in 40-design.md. */
export interface IdentityPayload {
packageName: string;
version: string;
}
/** Cap on echoed probe output so a warning can never dump a whole usage screen. */
const EVIDENCE_MAX_CHARS = 200;
function excerpt(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > EVIDENCE_MAX_CHARS ? `${flat.slice(0, EVIDENCE_MAX_CHARS)}…` : flat;
}
/**
* `stdout` as a plain JSON object, or `null` when it is not one.
*
* `JSON.parse` accepts `0`, `"str"`, `[]`, `null` and `true`; arrays and `null`
* are also `object` to `typeof`. All are rejected here so no downstream
* truthiness check can mistake `[]` for a verified identity.
*/
function parseIdentityRecord(stdout: string): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(stdout) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
}
/**
* Pure classifier: probe result -> verdict. Total — never throws, for any input.
*
* Non-object JSON (`0`, `"str"`, `[]`, `null`, `true`) is `unparseable`, not
* `ok` and not a crash: `JSON.parse` accepts all of them, so a naive truthiness
* check would let `[]` through as a verified identity.
*/
export function classifyIdentityProbe(
probe: IdentityProbe,
expected: string = EXPECTED_PACKAGE_NAME,
): IdentityVerdict {
if (probe.spawnFailed || probe.timedOut) {
return {
reason: 'probe_failed',
expected,
detail: probe.timedOut ? 'identity probe timed out' : 'identity probe could not be spawned',
};
}
// PARSE BEFORE CONSULTING THE EXIT CODE (#3841).
//
// The exit-code rule below is right for its stated reason -- the predecessor
// prints a usage screen and exits 1, and that is "something else answered",
// not a parse problem -- but it used to run FIRST, so it also caught a tool
// that had already proved its identity and merely exited non-zero afterwards.
// The shell preamble this module speaks for reads stdout ONLY (its command
// substitution discards the status), so the two surfaces disagreed on exactly
// that input: shell `ok`, classifier `no_identity_verb`. Measured against the
// real snippet, not inferred. Since this classifier is the engine for the
// announced hard-fail phase, such an install would have flipped from verified
// to refused at rollout, in the phase where that stops the run.
//
// The predecessor defence is unaffected: a usage screen yields no usable
// payload, so it still falls through to the exit-code branch below.
const record = parseIdentityRecord(probe.stdout);
if (record !== null) {
const actual = record.packageName;
if (typeof actual === 'string' && actual.length > 0) {
// Unknown keys are ignored so a future payload addition cannot fail an
// older check. The shell anchor is deliberately open in the middle for the
// same reason, and the parity suite pins both halves of that (case P10).
const version = typeof record.version === 'string' ? record.version : undefined;
if (actual !== expected) return { reason: 'identity_mismatch', expected, actual, version };
// ANCHOR PARITY (#3841). The shell preamble cannot parse JSON; it matches
// a `case` pattern anchored at the START of stdout, so `packageName` must
// serialize first. A structural parse alone would accept
// `{"note":"x","packageName":"<us>"}` — a shape this package never emits —
// while the shell rejected it, so the two surfaces would disagree in the
// FAIL-OPEN direction. Honoring the anchor here is also what the module
// already claims to do: IDENTITY_RAW_PREFIX is documented as "ANCHORED,
// never a substring search", and buildIdentityPayload inserts packageName
// first precisely so a genuine payload always satisfies it.
if (!probe.stdout.startsWith(IDENTITY_RAW_PREFIX)) {
return {
reason: 'unparseable',
expected,
detail: 'payload names this package but is not in the anchored wire shape',
};
}
return { reason: 'ok', expected, actual, version };
}
}
// Nothing was proved. NOW the exit status is the discriminator: a non-zero
// exit means a binary that does not implement this verb answered.
if (probe.exitCode !== 0) {
return {
reason: 'no_identity_verb',
expected,
detail: `exit ${String(probe.exitCode)}: ${excerpt(probe.stdout)}`,
};
}
return {
reason: 'unparseable',
expected,
detail: record === null ? excerpt(probe.stdout) : 'payload has no usable packageName',
};
}
/**
* Collapse a five-way verdict onto the two-valued shell status.
*
* Total by construction: anything that is not `ok` is `unverified`. A future
* sixth reason code therefore fails CLOSED here rather than silently verifying.
*/
export function statusForVerdict(verdict: IdentityVerdict): IdentityStatus {
return verdict.reason === 'ok' ? IDENTITY_STATUS.OK : IDENTITY_STATUS.UNVERIFIED;
}
export interface PayloadDeps {
/** Injectable for tests; defaults to the shipped host-version reader. */
readVersion?: () => string;
/** Injectable for tests; defaults to the build-time baked package name. */
readPackageName?: () => string;
}
/**
* Build this runtime's identity payload.
*
* Version is REPORTED but not asserted in the warn phase, so a dev tree
* reporting readHostVersion()'s fail-closed `0.0.0` still verifies. Carrying it
* now means the hard-fail phase can gate on compatibility without a payload
* change (and therefore without a second round of Hyrum's-Law exposure).
*/
export function buildIdentityPayload(deps: PayloadDeps = {}): IdentityPayload {
const readVersion = deps.readVersion ?? ((): string => readHostVersion());
const readName = deps.readPackageName ?? ((): string => EXPECTED_PACKAGE_NAME);
return { packageName: readName(), version: readVersion() };
}
/** Render a verdict as the one-line actionable warning the preamble prints. */
export function explainVerdict(verdict: IdentityVerdict, resolvedPath: string): string {
const head = `WARNING: "${resolvedPath}" did not report a ${verdict.expected} identity.`;
const why: Record<IdentityReason, string> = {
ok: 'identity verified',
identity_mismatch: `it reported "${verdict.actual ?? '(unknown)'}" instead`,
no_identity_verb:
'it does not implement the runtime-identity verb — either a different package, or a gsd-core predating the verb',
unparseable: 'its response could not be parsed as an identity payload',
probe_failed: 'the identity probe could not be run',
};
const tail =
'A workflow shipped by this package may be running against a different tool. ' +
'See docs/how-to/diagnose-a-foreign-gsd-tools.md';
const evidence = verdict.detail ? ` (${verdict.detail})` : '';
return `${head} Reason: ${why[verdict.reason]}${evidence}. ${tail}`;
}
/**
* CLI arm for `gsd-tools runtime-identity`.
*
* Kept on the CJS fast path for the same reason as `current-timestamp`: it is a
* pure local read, and the launcher preamble spawns it once per workflow run,
* so SDK bridge startup would be a per-run tax on every workflow.
*/
export function cmdRuntimeIdentity(raw: boolean = false, deps: PayloadDeps = {}): void {
const payload = buildIdentityPayload(deps);
process.stdout.write(`${JSON.stringify(payload, null, raw ? 0 : 2)}\n`);
}