Files
msd-core/tests/shadow-report.security.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

381 lines
19 KiB
JavaScript

'use strict';
/**
* tests/shadow-report.security.test.cjs — hostile-manifest rendering suite
* for `install-shadow-report.cts` (#2873, epic #2866 Phase 4a — governed by
* `.msd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
*
* Implements matrix section B ("Rendering / sanitization (hostile manifest)",
* rows B1-B16) from
* `.msd/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 `msd-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('../msd-core/bin/lib/install-shadow-report.cjs');
const { resolveScope } = require('../msd-core/bin/lib/install-scope.cjs');
const { MANIFEST_NAME } = require('../msd-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) }));
}
// RLO (Right-to-Left Override, U+202E) — written as a `\u{...}` escape (not
// a literal bidi character) so the source stays plain ASCII and does not
// carry the very invisible/dangerous-Unicode class it tests. See the
// matching B17/B18 note further below for the same rationale.
const BIDI_RLO = '\u{202E}';
// ─── 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${BIDI_RLO}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('msd-shadow-sec-b56-home-');
const cwd = createTempDir('msd-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('msd-shadow-sec-b9-home-');
const cwd = createTempDir('msd-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/msd-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/msd-../../../x/SKILL.md': 'a',
'skills/msd-plan-phase/SKILL.md': 'b',
},
})],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/msd-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), ['msd-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\\msd-foo\\SKILL.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/msd-foo.md': 'a' } })],
]);
const report = buildShadowReport('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(report.triggers.map((t) => t.trigger), ['msd-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/msd-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/msd-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('msd-shadow-sec-b15-home-');
const cwd = createTempDir('msd-shadow-sec-b15-cwd-');
const outOfTreeDir = createTempDir('msd-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/msd-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/msd-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('msd-shadow-sec-b16-home-');
const cwd = createTempDir('msd-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/msd-plan-phase/SKILL.md': 'a' },
}));
fs.writeFileSync(path.join(localDir, MANIFEST_NAME), JSON.stringify({
manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/msd-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.