Files
msd-core/tests/shadow-report.security.test.cjs
sim 2641e6cb67 feat(#2873): detect cross-scope shadowing and reach the local spec tree
4a - the detection floor. A shadowed install now reports which triggers are
shadowed and which scope wins, at install time and through a new W028
/gsd-health diagnostic. Exit codes are untouched: a shadowed install is a
warning, not a failure. Only triggers whose stem exists at BOTH scopes are
reported, so a global full profile beside a local core profile no longer
names local artifacts the user does not have.

4b - spec-root reachability, claude runtime and global scope only. The
winning global skill stops carrying a static workflow @-include and instead
resolves its spec at runtime: prefer the project-local copy, fall back to
the global one, stop if neither exists. Every other @-include stays static,
and the local emission is byte-identical. It runs after the staged-skills
rewrite pass, whose claude branch would otherwise mangle the literal tilde
path into an undocumented $HOME form.

Also fixed inline: readInstallManifest classified a top-level JSON array as
an installed v1 manifest, because typeof [] is object.

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

331 lines
16 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);
});
});