Files
msd-core/tests/shadow-report.security.test.cjs
sim 147856040b fix(#2873): close review findings across fences, sanitizer and docs
Isolated security review found resolveSpecRootReference's fence tracker
toggled on any delimiter, so a backtick fence could be closed by a tilde
one and an include in the gap was rewritten inside a code block. Fixed by
reusing scanFencedBlocks - the canonical engine already behind
stripFencedCode and extractFencedBlock - rather than carrying a fourth
copy of fence detection, which also closes the duplication the standards
review flagged.

sanitizeForRender now strips combining marks and zero-width characters
alongside the ANSI, control and bidi classes it already handled.

Adds the C, E and F matrix rows the spec review found missing, including
installer-level coverage that spawns the real install rather than calling
the report builder. Ships the how-to, the reference and command docs in
five locales, the changeset, the inventory and glossary entries, and
regenerates health.md for the new W028 rule.

Refs #2873
2026-08-14 23:48:39 -04:00

375 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* tests/shadow-report.security.test.cjs — hostile-manifest rendering suite
* for `install-shadow-report.cts` (#2873, epic #2866 Phase 4a — governed by
* `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
*
* Implements matrix section B ("Rendering / sanitization (hostile manifest)",
* rows B1-B16) from
* `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The
* matrix's own "Suites" section names this file `install-shadow-report
* .security.test.cjs`; it is shipped as `shadow-report.security.test.cjs`
* instead so its `lint-test-file-count.cjs` prefix is `shadow` rather than
* colliding with the already grandfathered, already-at-cap `install` prefix.
*
* Fixture provenance (#2371, per the matrix's own note): B1-B4/B7/B8's
* `declaredRuntime` payloads and B9-B13's manifest bodies are authored
* against the PUBLISHED `gsd-file-manifest.json` schema/format directly (raw
* JSON text or a hand-built `readManifest` result), never derived from
* `writeManifest`'s own output — a fixture the writer produced could only
* confirm what the writer already believes.
*
* Every `declaredRuntime` assertion below reads the TYPED IR field
* (`report.mismatches[0].declaredRuntime`) produced by `buildShadowReport`'s
* sanitize-at-the-render-seam guarantee — never a substring match against
* rendered prose (CONTRIBUTING → "Prohibited: Raw Text Matching on Test
* Outputs"). Where a `renderShadowReport` line is also inspected (B2, B12),
* the check is a structural security invariant (absence of a control
* character / a traversal payload), not a wording assertion.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
const { MANIFEST_NAME } = require('../gsd-core/bin/lib/installer-migrations.cjs');
// ─── Fixture helpers (mirrors tests/installed-surface-resolver.test.cjs) ───
const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} });
function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) {
return { manifestVersion, runtime, scope, files };
}
function mkReadManifest(byConfigHome) {
return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST;
}
function scopeHomes(runtime, home, cwd) {
const base = { runtime, env: {}, home, existsSync: () => false, cwd };
return {
global: resolveScope({ ...base, id: 'global' }).configHome,
local: resolveScope({ ...base, id: 'local' }).configHome,
};
}
function baseOpts(home, cwd, overrides = {}) {
return { home, cwd, env: {}, existsSync: () => false, ...overrides };
}
/** Single-scope (global-only) fixture: a claude install declaring
* `declaredRuntime = runtimeVal` — always a mismatch against the requested
* 'claude' runtime unless `runtimeVal === 'claude'`, which is exactly what
* puts an entry in `report.mismatches` for every B-row below to inspect.
* `files: {}` keeps the fixture single-purpose: no trigger/shadowing signal
* competes with the mismatch signal under test. */
function declaredRuntimeReport(runtimeVal) {
const home = '/fixture/sec-home';
const cwd = '/fixture/sec-cwd';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: runtimeVal, scope: 'global', files: {} })],
]);
return buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
}
// ─── B1-B4 — hostile declaredRuntime payloads are neutralized in the IR ────
describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B1-B4)', () => {
test('ansi escape is neutralized (B1)', () => {
const report = declaredRuntimeReport('\x1b[31mcursor');
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'cursor');
assert.ok(!report.mismatches[0].declaredRuntime.includes('\x1b'));
});
test('newlines cannot forge a log line (B2)', () => {
const lf = declaredRuntimeReport('cursor\nFAKE LOG LINE');
const crlf = declaredRuntimeReport('cursor\r\nFAKE LOG LINE');
assert.strictEqual(lf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE');
assert.strictEqual(crlf.mismatches[0].declaredRuntime, 'cursorFAKE LOG LINE');
assert.ok(!lf.mismatches[0].declaredRuntime.includes('\n'));
// Every rendered line must itself be single-line — a structural check on
// the renderer's output shape, not a wording assertion.
for (const line of renderShadowReport(lf)) {
assert.ok(!line.includes('\n'), `rendered line must never carry an embedded newline: ${JSON.stringify(line)}`);
}
});
test('control characters are stripped (B3)', () => {
const report = declaredRuntimeReport('a\x00b\x07c');
assert.strictEqual(report.mismatches[0].declaredRuntime, 'abc');
});
test('bidi override is stripped (B4)', () => {
const report = declaredRuntimeReport('a‮b');
assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab');
});
});
// ─── B5-B6 — the READER's 64-char cap, real fs, no double-truncation ───────
describe('buildShadowReport — declaredRuntime length cap, real reader (B5-B6)', () => {
function realCappedReport(t, n) {
const home = createTempDir('gsd-shadow-sec-b56-home-');
const cwd = createTempDir('gsd-shadow-sec-b56-cwd-');
t.after(() => { cleanup(home); cleanup(cwd); });
const globalDir = path.join(home, '.claude');
fs.mkdirSync(globalDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
manifestVersion: 2, runtime: 'A'.repeat(n), scope: 'global', files: {},
}));
return buildShadowReport('claude', { home, cwd });
}
test('cap-length runtime renders intact (B5, 64 chars)', (t) => {
const report = realCappedReport(t, 64);
assert.strictEqual(report.mismatches[0].declaredRuntime.length, 64);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'A'.repeat(64));
});
test('reader cap is respected once, not double-truncated (B6, 63/65 chars)', (t) => {
const below = realCappedReport(t, 63);
assert.strictEqual(below.mismatches[0].declaredRuntime, 'A'.repeat(63));
const above = realCappedReport(t, 65);
// readInstallManifest's MAX_REPORTED_RUNTIME_LENGTH truncates to 64 chars
// plus an ellipsis (65 chars total) — buildShadowReport's sanitizer never
// truncates further, so the ellipsis must survive intact.
assert.strictEqual(above.mismatches[0].declaredRuntime.length, 65);
assert.strictEqual(above.mismatches[0].declaredRuntime, `${'A'.repeat(64)}…`);
});
});
// ─── B7-B8 — empty vs null declaredRuntime ─────────────────────────────────
describe('buildShadowReport — empty vs absent declaredRuntime (B7-B8)', () => {
test('empty declared runtime renders as empty string, never as the string "null" (B7)', () => {
// Injected directly (bypassing readInstallManifest's own empty-string ->
// null normalization) so this exercises buildShadowReport/sanitizeForRender's
// OWN handling of an empty-but-present declared value, independent of
// the reader's separate empty-string rule.
const report = declaredRuntimeReport('');
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, '');
assert.notStrictEqual(report.mismatches[0].declaredRuntime, null);
});
test('absent declared runtime (null, v1 manifest) is omitted from the IR entirely (B8)', () => {
const home = '/fixture/b8-home';
const cwd = '/fixture/b8-cwd';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
// scope matches probe too, so NEITHER mismatch flag fires.
[homes.global, manifest({ manifestVersion: 2, runtime: null, scope: 'global', files: {} })],
]);
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(report.mismatches, [], 'a null declaredRuntime with no scope mismatch produces no mismatch entry at all');
});
});
// ─── B9-B11 — manifest document malformation, real files, real reads ──────
describe('buildShadowReport — malformed manifest documents degrade, never throw (B9-B11)', () => {
function realSingleScopeReport(t, rawBody) {
const home = createTempDir('gsd-shadow-sec-b9-home-');
const cwd = createTempDir('gsd-shadow-sec-b9-cwd-');
t.after(() => { cleanup(home); cleanup(cwd); });
const globalDir = path.join(home, '.claude');
fs.mkdirSync(globalDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), rawBody);
let report;
assert.doesNotThrow(() => {
report = buildShadowReport('claude', { home, cwd });
});
return report;
}
test('non-object manifest json (0, string, array, boolean, null) all degrade to not-installed (B9)', (t) => {
for (const raw of ['0', '"a string"', '[]', 'true', 'null']) {
const report = realSingleScopeReport(t, raw);
assert.strictEqual(report.shadowed, false, `raw body ${raw} must degrade to not-installed`);
assert.deepStrictEqual(report.triggers, []);
}
});
test('an empty (0-byte) manifest file degrades to not-installed (B10)', (t) => {
const report = realSingleScopeReport(t, '');
assert.strictEqual(report.shadowed, false);
assert.deepStrictEqual(report.triggers, []);
});
test('a CRLF manifest parses identically to its LF counterpart (B11)', (t) => {
const lfBody = [
'{',
' "manifestVersion": 2,',
' "runtime": "claude",',
' "scope": "global",',
' "files": { "skills/gsd-plan-phase/SKILL.md": "a" }',
'}',
'',
].join('\n');
const lfReport = realSingleScopeReport(t, lfBody);
const crlfReport = realSingleScopeReport(t, lfBody.replace(/\n/g, '\r\n'));
assert.deepStrictEqual(crlfReport, lfReport);
});
});
// ─── B12-B13 — manifest key hostility / cross-platform normalization ──────
describe('buildShadowReport — manifest KEY hostility and normalization (B12-B13)', () => {
test('a traversal stem is rejected, never reaches a rendered trigger (B12)', () => {
const home = '/fixture/b12-home';
const cwd = '/fixture/b12-cwd';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({
manifestVersion: 2, runtime: 'claude', scope: 'global',
files: {
'skills/gsd-../../../x/SKILL.md': 'a',
'skills/gsd-plan-phase/SKILL.md': 'b',
},
})],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
// Only the legitimate stem is present — the traversal key contributed nothing.
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-plan-phase']);
for (const line of renderShadowReport(report)) {
assert.ok(!line.includes('..'), `rendered output must never carry a traversal payload: ${JSON.stringify(line)}`);
}
});
test('backslash-separated keys normalize on posix too (B13)', () => {
const home = '/fixture/b13-home';
const cwd = '/fixture/b13-cwd';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills\\gsd-foo\\SKILL.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-foo.md': 'a' } })],
]);
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['gsd-foo']);
});
});
// ─── B14-B16 — the lstatSync symlink guard ─────────────────────────────────
describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => {
test('a symlinked local config dir is not followed, injected lstatSync (B14)', () => {
const home = '/fixture/b14-home';
const cwd = '/fixture/b14-cwd';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
// A manifest IS present at the local configHome per this readManifest
// stub — proving the guard, not the reader, is what refuses it below.
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
// Injected lstatSync reports the local configHome itself as a symlink.
const lstatSync = (p) => ({ isSymbolicLink: () => p === homes.local });
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), lstatSync }));
assert.strictEqual(report.shadowed, false, 'the symlinked local scope must not be counted as installed, so nothing can shadow it');
assert.deepStrictEqual(report.triggers, []);
});
test('a symlinked manifest file is not followed, real symlink on disk (B15)', (t) => {
const home = createTempDir('gsd-shadow-sec-b15-home-');
const cwd = createTempDir('gsd-shadow-sec-b15-cwd-');
const outOfTreeDir = createTempDir('gsd-shadow-sec-b15-outoftree-');
t.after(() => { cleanup(home); cleanup(cwd); cleanup(outOfTreeDir); });
const globalDir = path.join(home, '.claude');
const localDir = path.join(cwd, '.claude');
fs.mkdirSync(globalDir, { recursive: true });
fs.mkdirSync(localDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' },
}));
const outOfTreeManifest = path.join(outOfTreeDir, 'real-manifest.json');
fs.writeFileSync(outOfTreeManifest, JSON.stringify({
manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' },
}));
// The local config DIR is real; only the manifest FILE inside it is a
// symlink pointing OUTSIDE the config dir — proves the guard checks the
// manifest path itself, not merely the directory.
fs.symlinkSync(outOfTreeManifest, path.join(localDir, MANIFEST_NAME), 'file');
const report = buildShadowReport('claude', { home, cwd });
assert.strictEqual(report.shadowed, false, 'a symlinked manifest file must never be followed, even though its target is valid, matching content');
assert.deepStrictEqual(report.triggers, []);
});
test('an unsymlinked local config still reads (B16, negative proof)', (t) => {
const home = createTempDir('gsd-shadow-sec-b16-home-');
const cwd = createTempDir('gsd-shadow-sec-b16-cwd-');
t.after(() => { cleanup(home); cleanup(cwd); });
const globalDir = path.join(home, '.claude');
const localDir = path.join(cwd, '.claude');
fs.mkdirSync(globalDir, { recursive: true });
fs.mkdirSync(localDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, MANIFEST_NAME), JSON.stringify({
manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' },
}));
fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({
manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' },
}));
const report = buildShadowReport('claude', { home, cwd });
assert.strictEqual(report.shadowed, true, 'the guard must not break the ordinary, unsymlinked happy path');
assert.strictEqual(report.triggers.length, 1);
});
});
// ─── B17-B18 — zalgo / zero-width, #2873 PR review Finding 2 (MINOR) ──────
//
// `sanitizeForRender` stripped ANSI, C0/C1, and bidi overrides/isolates, but
// not combining marks (U+0300-U+036F — "zalgo" text, which visually
// overflows into adjacent terminal cells) or zero-width characters (ZWSP
// U+200B, ZWNJ U+200C, ZWJ U+200D, BOM/ZWNBSP U+FEFF). Neither class is a JS
// `\s`, so both survived the 64-char cap and the whitespace-collapse step
// undetected.
// Written as `\u{...}` escapes throughout (never literal combining/bidi/
// zero-width characters) so the source stays plain ASCII and does not
// visually combine with adjacent punctuation in editors/diffs.
const ZALGO_COMBINING_1 = '\u{0300}'; // combining grave accent
const ZALGO_COMBINING_2 = '\u{0301}'; // combining acute accent
const ZALGO_COMBINING_3 = '\u{036F}'; // combining latin small letter x (top of range)
const ZWSP = '\u{200B}';
const ZWNJ = '\u{200C}';
const ZWJ = '\u{200D}';
const BOM = '\u{FEFF}';
describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B17-B18)', () => {
test('combining marks (zalgo) are stripped (B17)', () => {
const report = declaredRuntimeReport(`a${ZALGO_COMBINING_1}${ZALGO_COMBINING_2}${ZALGO_COMBINING_3}b`);
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab');
assert.ok(!/[\u{0300}-\u{036F}]/u.test(report.mismatches[0].declaredRuntime));
});
test('zero-width characters (ZWSP/ZWNJ/ZWJ/BOM) are stripped (B18)', () => {
const report = declaredRuntimeReport(`a${ZWSP}b${ZWNJ}c${ZWJ}d${BOM}e`);
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'abcde');
assert.ok(!/[\u{200B}-\u{200D}\u{FEFF}]/u.test(report.mismatches[0].declaredRuntime));
});
});
// Note: the F3 property ("sanitizer output contains no character in the
// stripped class, for arbitrary input") lives in `tests/shadow-report.test.cjs`
// alongside F2 (sanitizer idempotence) — both target `sanitizeForRender` and
// share one hostile-input generator, extended for #2873 PR review Finding 2
// (MINOR) to also emit combining marks (zalgo) and zero-width characters so
// the property actually exercises the newly-stripped classes rather than
// passing vacuously.