Files
msd-core/tests/config-get-raw-guard.test.cjs
Tom Boucher 52b11ee811 fix(#3763): pass --raw at every shipped config-get bash call site (#3961)
* test(#3763): guard every shipped config-get substitution on --raw

* fix(#3763): pass --raw at every shipped config-get bash call site

config-get without --raw prints JSON.stringify(value), so string-typed values
reach bash with literal quotes and every string comparison silently never
matches (#3763). --raw added at 75 command-substitution sites across shipped
content; four JSON consumers (default_reviewers, sub_repos, pr_body_sections,
code_review_depth_overrides) deliberately keep default JSON output.

Emitted-Drift-Ack-Growth: ai-integration-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: audit-fix.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: autonomous.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: cleanup.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: code-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: complete-milestone.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: discuss-phase-assumptions.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: do.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: eval-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: execute-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: execute-plan.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: fast.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: graduation.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: gsd-executor.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: health.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: import.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: inbox.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: ingest-docs.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: mvp-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: new-milestone.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: next.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: plan-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: plan-review-convergence.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: plant-seed.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: profile-user.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: progress.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: quick.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: remove-workspace.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: secure-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: settings-integrations.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: settings.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: ship.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: sketch-wrap-up.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: sketch.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: smart-entry.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: spike-wrap-up.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: spike.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: ui-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: ui-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: undo.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted
Emitted-Drift-Ack-Growth: validate-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted

* chore(#3763): changeset fragment (pr number backfilled after PR creation)

* chore(#3763): backfill changeset PR number (3961)

---------

Co-authored-by: sim <sim@local>
2026-08-27 19:55:34 -04:00

92 lines
4.2 KiB
JavaScript

'use strict';
// ─────────────────────────────────────────────────────────────────────────────
// #3763 — every `config-get` command substitution in shipped content passes
// `--raw` (or is an exempt JSON consumer).
//
// `query config-get <key>` without `--raw` prints `JSON.stringify(value)`, so
// a STRING-typed value reaches a bash variable with embedded literal quotes
// (`RUNTIME='"claude"'`) and every downstream `[ "$X" = "y" ]` / `case "$X"`
// comparison silently never matches. Boolean and numeric values are identical
// either way, which is exactly why the string sites survived testing.
//
// Exempt shapes (consumers that WANT JSON — an object/array value):
// * the receiving variable's name ends in `_JSON`
// * the call carries a JSON-array default (`--default '[]'` / `--default "[]"`)
// Anything else that command-substitutes config-get must pass `--raw`.
// ─────────────────────────────────────────────────────────────────────────────
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
const SCAN_ROOTS = [
'gsd-core/workflows',
'commands',
'agents',
'skills',
];
function walk(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
}
return out;
}
// A config-get COMMAND SUBSTITUTION: any command substitution containing
// config-get — `$(gsd_run query config-get ...)`, `$(gsd-tools ... config-get
// ...)`, etc. Prose mentions (no `$(`) do not match; nested `$( )` inside the
// substitution is not a shape the shipped trees use for config-get calls.
const SUBSTITUTION_RE = /\$\([^)]*config-get[^)]*\)/g;
test('#3763: every config-get command substitution in shipped content passes --raw (or is an exempt JSON consumer)', () => {
const files = [];
for (const root of SCAN_ROOTS) walk(path.join(REPO_ROOT, root), files);
const offenders = [];
let scannedSubstitutions = 0;
for (const file of files) {
const rel = path.relative(REPO_ROOT, file);
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes('config-get')) continue;
const subs = line.match(SUBSTITUTION_RE) || [];
for (const sub of subs) {
scannedSubstitutions++;
// --raw must sit in the config-get command itself, before any `||`
// fallback — an `echo "" --raw` fallback arg would otherwise
// false-pass the check while config-get still lacks the flag.
const cmd = sub.slice(0, sub.indexOf('||') >= 0 ? sub.indexOf('||') : sub.length);
if (cmd.includes('--raw')) continue;
// Exempt shapes: a JSON-consuming variable name, or an explicit
// JSON-array default — the caller wants JSON.stringify output.
const varMatch = /^\s*[A-Za-z_][A-Za-z0-9_]*=/.exec(line);
const varName = varMatch ? varMatch[0].trimEnd().slice(0, -1) : '';
if (varName.endsWith('_JSON')) continue;
if (/--default\s+('\[\]'|"\[\]")/.test(sub)) continue;
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
}
}
}
assert.ok(scannedSubstitutions > 20,
`guard self-check: expected to scan dozens of config-get substitutions, found ${scannedSubstitutions} — the scan roots or matcher rotted`);
assert.deepEqual(
offenders,
[],
`#3763: config-get command substitutions without --raw feed JSON.stringify output into bash string comparisons (they silently never match for string values). Add --raw, or rename the receiving variable to *_JSON / pass a '[]' default if the consumer parses JSON. Offenders:\n${offenders.join('\n')}`,
);
});