// policy-scope.test.mjs — .llm-security/policy.json (and the custom SIG // ruleset it can point at) must never be honored for a scanned target that is // not the user's own working tree (S3c, v8.1.0, 2026-09-22). // // scan-orchestrator.mjs, entropy-scanner.mjs, signature-scanner.mjs, // trigger-scanner.mjs and ast-taint-scanner.mjs all call // loadPolicy()/getPolicyValue() with the SCANNED TARGET as the root. A // foreign/cloned target could therefore ship its own policy.json that raises // the entropy thresholds past any real value (silencing a known finding) and // points `sig.custom_rules_path` at a ruleset of its own choosing — a hostile // repo configuring the scan of itself. Same defect class as S3b's // .llm-security-ignore fix and the v8.0.0 commons-root fix. // // Scenarios (known finding = a random-bytes base64 blob in a .js file, which // the entropy scanner classifies HIGH — same fixture S3b uses): // FOREIGN target (absolute path under os.tmpdir(), scanned from this repo's // cwd — what git-clone.mjs hands the orchestrator): the envelope with the // hostile policy.json must equal the envelope of the same tree WITHOUT // the policy file — same verdict, same findings. The custom SIG rule must // not fire, the entropy finding must survive, and stderr must say the // policy was not honored. // OWN working tree (cwd === target, arg '.', outside os.tmpdir() and outside // this repo's git tree — see ignore-file-scope.test.mjs for why): the same // policy.json MUST still be honored. This is the known-positive that keeps // the FOREIGN equality from being vacuous: it proves the fixture's policy // really would silence the entropy finding and really would add the // custom SIG finding. // loadPolicy() unit level: an explicit foreign root yields defaults; the // implicit root (CLAUDE_PROJECT_ROOT / cwd — what every hook uses) is // still read, so the hooks are untouched. import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import crypto from 'node:crypto'; import { loadPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ORCHESTRATOR = resolve(__dirname, '../../scanners/scan-orchestrator.mjs'); const REPO_ROOT = resolve(__dirname, '../..'); // Not a real credential — a known-positive blob for the entropy scanner only. const HIGH_ENTROPY_BLOB = crypto.randomBytes(72).toString('base64'); const CUSTOM_MARKER = 'POLICYSCOPEMARKER_7731'; const CUSTOM_RULE_ID = 'CUSTOM-SCOPE-001'; const UNREACHABLE = { entropy: 99, minLen: 1_000_000 }; const HOSTILE_POLICY = { entropy: { thresholds: { critical: UNREACHABLE, high: UNREACHABLE, medium: UNREACHABLE }, }, sig: { custom_rules_path: 'custom-sigs.json' }, }; function writeFixture(dir, { withPolicy }) { writeFileSync(join(dir, 'config.js'), `const payload = "${HIGH_ENTROPY_BLOB}";\nmodule.exports = { payload };\n`); writeFileSync(join(dir, 'notes.txt'), `prefix ${CUSTOM_MARKER} suffix\n`); writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({ rules: [{ id: CUSTOM_RULE_ID, family: 'webshell', severity: 'high', pattern: 'POLICYSCOPEMARKER_[0-9]+', description: 'Target-supplied custom rule (must only load for the own working tree)', }], })); if (withPolicy) { mkdirSync(join(dir, '.llm-security'), { recursive: true }); writeFileSync(join(dir, '.llm-security', 'policy.json'), JSON.stringify(HOSTILE_POLICY)); } } function runOrchestrator(target, cwd) { return new Promise((resolveP) => { const stdout = []; const stderr = []; const child = spawn('node', [ORCHESTRATOR, target], { cwd, timeout: 180_000, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (c) => stdout.push(c)); child.stderr.on('data', (c) => stderr.push(c)); child.on('close', (code) => { resolveP({ code: code ?? 1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }); }); }); } const entropyFindings = (env) => env?.scanners?.entropy?.findings || []; const customSigFindings = (env) => (env?.scanners?.sig?.findings || []) .filter((f) => String(f.evidence || '').includes(CUSTOM_RULE_ID)); /** Order-independent, id-independent fingerprint of every finding. */ function findingKeys(env) { const keys = []; for (const [name, result] of Object.entries(env?.scanners || {})) { for (const f of result.findings || []) { keys.push(`${name}|${f.severity}|${f.title}|${f.file}|${f.line ?? ''}`); } } return keys.sort(); } describe('.llm-security/policy.json is scoped to the user\'s own working tree (S3c)', () => { describe('FOREIGN target: absolute path under os.tmpdir(), scanned from a different cwd', () => { let withDir; let withoutDir; let withRun; let withEnv; let withoutEnv; before(async () => { // Both trees share one parent so the only difference the scanners can // see is the presence of .llm-security/policy.json. const parent = mkdtempSync(join(tmpdir(), 'policy-scope-foreign-')); withDir = join(parent, 'target'); withoutDir = join(parent, 'target-nopolicy'); mkdirSync(withDir); mkdirSync(withoutDir); writeFixture(withDir, { withPolicy: true }); writeFixture(withoutDir, { withPolicy: false }); withRun = await runOrchestrator(withDir, REPO_ROOT); withEnv = JSON.parse(withRun.stdout); withoutEnv = JSON.parse((await runOrchestrator(withoutDir, REPO_ROOT)).stdout); }); after(() => { rmSync(dirname(withDir), { recursive: true, force: true }); }); it('the known HIGH entropy finding survives the target\'s raised thresholds', () => { assert.equal(entropyFindings(withEnv).length, 1, 'entropy thresholds from a foreign target\'s policy.json must not be applied'); }); it('the target-supplied custom SIG rule is not loaded', () => { assert.equal(customSigFindings(withEnv).length, 0, 'sig.custom_rules_path from a foreign target\'s policy.json must not be loaded'); }); it('same verdict as the same tree without policy.json', () => { assert.equal(withEnv.aggregate.verdict, withoutEnv.aggregate.verdict); }); it('same findings as the same tree without policy.json', () => { assert.deepEqual(findingKeys(withEnv), findingKeys(withoutEnv)); }); it('entropy calibration reports the defaults as its policy source, not the ignored file', () => { assert.equal(withEnv.scanners.entropy.calibration.policy_source, 'defaults'); }); it('logs a stderr line stating the policy file was not honored, and why', () => { assert.match(withRun.stderr, /policy\.json.*ignored/i, 'a foreign-target policy override must be loud, not silent'); }); }); describe('OWN working tree: `node scanners/scan-orchestrator.mjs .` (known-positive)', () => { let ownDir; let env; before(async () => { // Outside os.tmpdir() AND outside this repo's git tree — same placement // rule as ignore-file-scope.test.mjs. ownDir = mkdtempSync(join(homedir(), '.policy-scope-own-')); writeFixture(ownDir, { withPolicy: true }); env = JSON.parse((await runOrchestrator('.', ownDir)).stdout); }); after(() => { rmSync(ownDir, { recursive: true, force: true }); }); it('the raised thresholds silence the entropy finding', () => { assert.equal(entropyFindings(env).length, 0, 'policy.json in the caller\'s own working tree must still be honored'); }); it('the custom SIG rule loads and fires', () => { assert.equal(customSigFindings(env).length, 1, 'sig.custom_rules_path in the caller\'s own working tree must still load'); }); }); describe('loadPolicy() root handling', () => { let dir; let prevRoot; before(() => { dir = mkdtempSync(join(tmpdir(), 'policy-scope-unit-')); writeFixture(dir, { withPolicy: true }); prevRoot = process.env.CLAUDE_PROJECT_ROOT; }); after(() => { if (prevRoot === undefined) delete process.env.CLAUDE_PROJECT_ROOT; else process.env.CLAUDE_PROJECT_ROOT = prevRoot; _resetCacheForTest(); rmSync(dir, { recursive: true, force: true }); }); it('an explicit foreign root (under os.tmpdir(), not under cwd) yields the defaults', () => { _resetCacheForTest(); const policy = loadPolicy(dir); assert.equal(policy.sig.custom_rules_path, null); assert.equal(policy.entropy.thresholds.high.entropy, 5.1); }); it('the implicit root (CLAUDE_PROJECT_ROOT, as the hooks use it) is still read', () => { _resetCacheForTest(); process.env.CLAUDE_PROJECT_ROOT = dir; const policy = loadPolicy(); assert.equal(policy.sig.custom_rules_path, 'custom-sigs.json'); }); }); });