fix(policy): read policy.json only from the caller's own working tree

loadPolicy() read .llm-security/policy.json from whatever root it was
given, and every scanner passes the SCANNED TARGET: scan-orchestrator
(policyRoot = resolve(args.target)), entropy-scanner (thresholds and
suppression patterns), signature-scanner (sig.custom_rules_path and
enabled_families), trigger-scanner (phrase lists) and ast-taint-scanner
(enabled, python_path). A foreign/cloned target could raise its own
entropy thresholds, disable SIG families, supply its own SIG ruleset or
name the interpreter the AST scanner spawns — configuring the scan of
itself. Same defect class as S3b's .llm-security-ignore fix.

Chosen: move isOwnWorkingTree() to scanners/lib/own-working-tree.mjs (one
copy, reused by the orchestrator's ignore-file check) and make
loadPolicy() refuse an EXPLICIT root that is not the caller's own tree —
defaults plus one stderr line, same form as S3b — because one rule in one
function covers every scanner and a future call site cannot forget it.
The IMPLICIT root (CLAUDE_PROJECT_ROOT/cwd, what every hook uses) is the
caller's own project by construction and is read as before.
entropy-scanner's calibration.policy_source no longer reports an ignored
file as its source.

New tests/scanners/policy-scope.test.mjs was red on 0d37f5a (foreign
target: entropy finding silenced, custom SIG rule loaded, findings differ
from the same tree without policy.json, no stderr line) and is green now;
its own-tree scenario (known-positive) is green before and after. The 15
existing policy tests that placed own-tree fixtures under os.tmpdir() now
use tests/helpers/own-tree.mjs (fixture under $HOME, cwd set to it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 20:15:17 +02:00
commit 6d0f3c31fc
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
12 changed files with 364 additions and 50 deletions

View file

@ -15,6 +15,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/entropy-scanner.mjs';
@ -212,7 +213,7 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
describe('C. Policy-driven overrides', () => {
it('user-policy suppress_line_patterns adds custom suppression', async () => {
const fx = await newRoot('ent-policy-');
const fx = mkOwnTreeDir('ent-policy-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'secret.js', 'const vendor = "' + PAYLOAD + '"; // MY_VENDOR_MARKER');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
entropy: { suppress_line_patterns: ['MY_VENDOR_MARKER'] }
@ -220,14 +221,14 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 0, 'expected user pattern to suppress');
assert.equal(result.calibration.policy_source, 'policy.json');
await rm(fx, { recursive: true, force: true });
});
it('user-policy suppress_paths skips files whose relPath contains the substring', async () => {
const fx = await newRoot('ent-paths-');
const fx = mkOwnTreeDir('ent-paths-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'src/vendored/big.js', 'var x="' + PAYLOAD + '";');
await writeFixture(fx, 'src/app.js', 'var y="' + PAYLOAD + '";');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
@ -236,14 +237,14 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 1, 'Expected 1 finding (app.js only), got ' + result.findings.length);
assert.ok(result.calibration.files_skipped_by_path >= 1);
await rm(fx, { recursive: true, force: true });
});
it('user-policy stricter thresholds suppress medium-strength payload', async () => {
const fx = await newRoot('ent-thresh-');
const fx = mkOwnTreeDir('ent-thresh-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'cfg.js', 'const blob = "' + PAYLOAD + '";');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
entropy: {
@ -257,7 +258,7 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 0, 'expected strict thresholds to suppress medium-strength payload');
await rm(fx, { recursive: true, force: true });
});