The invisible-Unicode scan flagged the compiled sanitizer: BIDI_RE in
install-shadow-report.cts carried literal U+202A-U+202E where its two
neighbouring regexes already used \u{...} escapes, so the module that
strips bidi controls was itself a carrier for them.
The prompt-injection-scan failure alongside it was the same defect rolling
up through the parent describe, not a second cause - verified by running
the scanner across every category it checks.
Test fixtures and property generators now name their codepoints (RLO, LRE,
PDI) instead of embedding invisible bytes, so a reviewer can see which
character is under test.
Refs #2873
381 lines
19 KiB
JavaScript
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
|
|
* `.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) }));
|
|
}
|
|
|
|
// 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('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.
|