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

@ -16,6 +16,8 @@ import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
// S3c: policy.json is read only for the caller's own working tree.
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { spawnSync } from 'node:child_process';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
@ -100,7 +102,7 @@ describe('ast-taint-scanner: parse-only safety', () => {
describe('ast-taint-scanner: python3 absent', () => {
it('returns status skipped when the interpreter is unavailable', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ast-nopy-'));
const dir = mkOwnTreeDir('ast-nopy-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -110,7 +112,7 @@ describe('ast-taint-scanner: python3 absent', () => {
writeFileSync(join(dir, 'a.py'), 'import os\nk = os.environ["X"]\neval(k)\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'skipped');
assert.equal(result.findings.length, 0);
} finally {
@ -165,7 +167,7 @@ describe('ast-taint-scanner: orchestrator registration', () => {
describe('ast-taint-scanner: policy disable', () => {
it('enabled:false in policy.json short-circuits to skipped', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ast-off-'));
const dir = mkOwnTreeDir('ast-off-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -175,7 +177,7 @@ describe('ast-taint-scanner: policy disable', () => {
writeFileSync(join(dir, 'a.py'), 'import os\nk = os.environ["X"]\neval(k)\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'skipped');
assert.equal(result.findings.length, 0);
} finally {