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

@ -11,7 +11,8 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } 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 { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/signature-scanner.mjs';
@ -24,7 +25,7 @@ function writePolicy(dir, policy) {
describe('signature-scanner: custom_rules_path (#36)', () => {
it('loads and applies custom rules supplied via policy', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -40,7 +41,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const custom = result.findings.find(f => f.evidence && f.evidence.includes('CUSTOM-WS-001'));
assert.ok(
@ -53,7 +54,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('custom rules merge with (not replace) the built-in ruleset', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -70,7 +71,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(
@ -83,14 +84,14 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('fails gracefully when custom_rules_path points at a missing file', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'does-not-exist.json' } });
writeFileSync(join(dir, 'shell.php'), "<?php @ev" + "al($_POST['cmd']); ?>\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, 'ok', 'missing custom ruleset must not error the scan');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(builtin, 'built-in ruleset should still apply when custom file is missing');
@ -109,7 +110,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
// single occurrence of o/b/j/e/c/t/space, which is nearly every file. So
// the rule must be dropped by compileRules's type check, not left for the
// compile try/catch, which never sees an error here.
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -124,7 +125,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const bad = result.findings.find(f => f.evidence && f.evidence.includes('CUSTOM-OBJ-001'));
assert.equal(
@ -137,7 +138,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('fails gracefully when the custom ruleset is invalid JSON', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'broken.json' } });
writeFileSync(join(dir, 'broken.json'), '{ not json');
@ -145,7 +146,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok', 'invalid custom ruleset must not error the scan');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(builtin, 'built-in ruleset should still apply when custom file is invalid');