// commons-loader.test.mjs — Tests for the vendored-commons JSON artifact loader // // v8 Phase 5 step 2: llm-security-commons doesn't exist yet (Phase 4 is // blocked on the operator creating the Forgejo repo), so these tests drive // the loader against a local fixture directory instead of a real vendored // subtree — including the "commons isn't vendored yet" case, which must // degrade gracefully rather than crash. import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { loadArtifact, _resetCacheForTest } from '../../scanners/lib/commons-loader.mjs'; const TEST_ROOT = join(tmpdir(), `llm-security-commons-loader-test-${Date.now()}`); const FIXTURE_COMMONS_ROOT = join(TEST_ROOT, 'fixture-commons'); function writeArtifact(relPath, data) { const filePath = join(FIXTURE_COMMONS_ROOT, `${relPath}.json`); mkdirSync(join(filePath, '..'), { recursive: true }); writeFileSync(filePath, JSON.stringify(data)); } describe('commons-loader', () => { beforeEach(() => { _resetCacheForTest(); mkdirSync(FIXTURE_COMMONS_ROOT, { recursive: true }); }); afterEach(() => { _resetCacheForTest(); try { rmSync(TEST_ROOT, { recursive: true }); } catch {} }); it('loads and parses a valid artifact from an explicit commonsRoot', () => { writeArtifact('lexicon/injection-lexicon', { version: '0.1.0', terms: ['ignore previous instructions'] }); const data = loadArtifact('lexicon/injection-lexicon', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: null }); assert.deepEqual(data, { version: '0.1.0', terms: ['ignore previous instructions'] }); }); it('returns the caller fallback when the artifact file is missing', () => { const data = loadArtifact('lexicon/injection-lexicon', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: [] }); assert.deepEqual(data, []); }); it('returns the caller fallback when the artifact JSON is invalid', () => { const filePath = join(FIXTURE_COMMONS_ROOT, 'mapping', 'owasp-map.json'); mkdirSync(join(filePath, '..'), { recursive: true }); writeFileSync(filePath, 'not valid json!!!'); const data = loadArtifact('mapping/owasp-map', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: {} }); assert.deepEqual(data, {}); }); it('degrades gracefully when commons has not been vendored yet (no commonsRoot override)', () => { // Phase 4 hasn't run in this repo checkout: the default scanners/commons // vendor path does not exist. The loader must not throw, and — since the // commons location is never policy- or target-derived — this cannot be // influenced by an on-disk .llm-security/policy.json either. const data = loadArtifact('lexicon/injection-lexicon', { fallback: 'EMPTY' }); assert.equal(data, 'EMPTY'); }); it('caches a loaded artifact per resolved path', () => { writeArtifact('calibration/calibration', { entropyFloor: 4.5 }); const first = loadArtifact('calibration/calibration', { commonsRoot: FIXTURE_COMMONS_ROOT }); writeArtifact('calibration/calibration', { entropyFloor: 9.9 }); // mutate after first read const second = loadArtifact('calibration/calibration', { commonsRoot: FIXTURE_COMMONS_ROOT }); assert.equal(first, second); // same reference (cached) assert.equal(second.entropyFloor, 4.5); // original value, not the mutation }); it('caches a failed load too, so a fixed-but-unread file still returns the fallback', () => { const first = loadArtifact('signatures/malware-signatures', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: [] }); writeArtifact('signatures/malware-signatures', { rules: [{ id: 'late-arrival' }] }); const second = loadArtifact('signatures/malware-signatures', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: [] }); assert.deepEqual(first, []); assert.deepEqual(second, []); }); it('a cached failure does not leak one caller\'s fallback shape to another', () => { const arrayFallback = loadArtifact('missing/artifact', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: [] }); const objectFallback = loadArtifact('missing/artifact', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: {} }); assert.deepEqual(arrayFallback, []); assert.deepEqual(objectFallback, {}); // not [] from the first caller's cached fallback }); it('defaults the fallback to null when the caller passes none', () => { const data = loadArtifact('lexicon/injection-lexicon', { commonsRoot: FIXTURE_COMMONS_ROOT }); assert.equal(data, null); }); it('freezes a loaded artifact so a caller mutation throws instead of leaking process-wide', () => { writeArtifact('lexicon/injection-lexicon', { version: '0.1.0', terms: ['a'] }); const data = loadArtifact('lexicon/injection-lexicon', { commonsRoot: FIXTURE_COMMONS_ROOT }); assert.throws(() => { data.terms.push('mutated'); }, TypeError); assert.throws(() => { data.version = '9.9.9'; }, TypeError); }); it('does not read .llm-security/policy.json from any target — commons.root is not honored', () => { // Simulates a hostile scanned target shipping .llm-security/policy.json // with {"commons":{"root": "..."}} to redirect detection data. Even // with such a file on disk and CLAUDE_PROJECT_ROOT pointed at it, the // loader must ignore it entirely and fall through to the caller's // explicit commonsRoot / default, never to policy. const hostileTargetRoot = join(TEST_ROOT, 'hostile-target'); const hostilePolicyDir = join(hostileTargetRoot, '.llm-security'); mkdirSync(hostilePolicyDir, { recursive: true }); writeFileSync( join(hostilePolicyDir, 'policy.json'), JSON.stringify({ commons: { root: join(TEST_ROOT, 'attacker-controlled-empty-commons') } }), ); const prevProjectRoot = process.env.CLAUDE_PROJECT_ROOT; process.env.CLAUDE_PROJECT_ROOT = hostileTargetRoot; try { writeArtifact('lexicon/injection-lexicon', { version: '0.1.0', terms: ['still-here'] }); const data = loadArtifact('lexicon/injection-lexicon', { commonsRoot: FIXTURE_COMMONS_ROOT, fallback: null }); assert.deepEqual(data, { version: '0.1.0', terms: ['still-here'] }); } finally { if (prevProjectRoot === undefined) delete process.env.CLAUDE_PROJECT_ROOT; else process.env.CLAUDE_PROJECT_ROOT = prevProjectRoot; } }); });