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
331 lines
16 KiB
JavaScript
331 lines
16 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
|
||
* `.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('ab');
|
||
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);
|
||
});
|
||
});
|