llm-security/scanners/lib/policy-loader.mjs
Kjell Tore Guttormsen 6d0f3c31fc
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>
2026-09-22 20:15:17 +02:00

226 lines
7.2 KiB
JavaScript

// policy-loader.mjs — Central policy file reader for distributable hook configuration
// Reads .llm-security/policy.json from project root. Falls back to defaults
// matching existing hardcoded behavior when no policy file exists.
// Zero external dependencies.
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { isOwnWorkingTree } from './own-working-tree.mjs';
// ---------------------------------------------------------------------------
// Default policy — matches all existing hardcoded values exactly
// ---------------------------------------------------------------------------
const DEFAULT_POLICY = Object.freeze({
version: '1.0',
injection: {
mode: 'block',
medium_advisory: true,
custom_patterns: [],
},
trifecta: {
mode: 'warn',
window_size: 20,
long_horizon_window: 100,
escalation_window: 5,
},
secrets: {
additional_patterns: [],
allowed_paths: [],
},
destructive: {
additional_blocked: [],
allowed_commands: [],
},
pathguard: {
additional_protected: [],
allowed_paths: [],
},
supply_chain: {
additional_blocked_packages: [],
trusted_registries: [],
},
mcp: {
trusted_servers: [],
volume_threshold_bytes: 100_000,
cumulative_drift_threshold: 0.25,
},
audit: {
log_path: null,
events: ['trifecta', 'injection', 'secrets', 'destructive'],
},
ci: {
failOn: null,
compact: false,
},
entropy: {
thresholds: {
critical: { entropy: 5.4, minLen: 128 },
high: { entropy: 5.1, minLen: 64 },
medium: { entropy: 4.7, minLen: 40 },
},
// User-extensible extension skip list — merged with built-in defaults.
suppress_extensions: [],
// Additional line-level regex sources (string or array of strings compiled at load).
suppress_line_patterns: [],
// Substring matches against relative path — plain contains, no glob.
suppress_paths: [],
},
// TRG — trigger/activation-abuse scanner. Lists mirror the scanner defaults
// in scanners/trigger-scanner.mjs; override any of them via policy.json.
trg: {
mode: 'warn',
baiting_phrases: [
'anything', 'everything', 'always', 'whenever', 'no matter what',
'any request', 'any task', 'any file', 'every time',
'all files', 'all messages', 'all requests',
],
builtin_names: [
'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task',
'webfetch', 'websearch', 'notebookedit', 'todowrite',
'ls', 'cat', 'agent', 'search', 'fetch',
],
broad_single_words: [
'run', 'do', 'go', 'help', 'fix', 'use', 'get', 'set', 'all', 'any', 'it', 'this', 'that',
'helper', 'assistant', 'auto', 'general', 'agent', 'tool',
],
},
// SIG — known-bad-identity signature engine. Toggle families or point at a
// custom ruleset via policy.json.
sig: {
enabled_families: ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'],
custom_rules_path: null,
},
// AST — Python AST taint scanner (shells out to a parse-only python3 helper).
ast: {
enabled: true,
python_path: 'python3',
timeout_ms: 5000,
},
});
// Cache loaded policy per project root
const cache = new Map();
/**
* Resolve project root from env or cwd.
* @param {string} [explicitRoot]
* @returns {string}
*/
function resolveRoot(explicitRoot) {
return explicitRoot || process.env.CLAUDE_PROJECT_ROOT || process.cwd();
}
/**
* Deep merge two objects (source overrides target).
* @param {object} target
* @param {object} source
* @returns {object}
*/
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] !== null &&
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
typeof target[key] === 'object' &&
!Array.isArray(target[key])
) {
result[key] = deepMerge(target[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* Load policy from .llm-security/policy.json.
* Returns defaults if no policy file exists or if parsing fails.
* Cached per project root (per process).
*
* S3c (v8.1.0, 2026-09-22): an EXPLICIT root is what the scanners pass — the
* scanned target. It is read only when it is the caller's own working tree
* (lib/own-working-tree.mjs, the rule S3b applied to .llm-security-ignore);
* a foreign/cloned target gets the defaults and one stderr line, so it can
* neither silence its own findings nor supply its own SIG ruleset
* (`sig.custom_rules_path`) or AST interpreter (`ast.python_path`). The
* IMPLICIT root (CLAUDE_PROJECT_ROOT or cwd — what every hook uses) is the
* caller's own project by construction and is read as before.
*
* @param {string} [projectRoot] - Explicit root, or derived from env/cwd
* @returns {object} Merged policy with defaults
*/
export function loadPolicy(projectRoot) {
const root = resolveRoot(projectRoot);
if (cache.has(root)) return cache.get(root);
const policyPath = join(root, '.llm-security', 'policy.json');
let policy;
if (projectRoot && !isOwnWorkingTree(root)) {
if (existsSync(policyPath)) {
console.error(
`[policy] ${policyPath}: policy.json was found but is ignored — ` +
`the scanned target is not the caller's own working directory (foreign/cloned target)`
);
}
policy = { ...DEFAULT_POLICY };
cache.set(root, policy);
return policy;
}
try {
const raw = readFileSync(policyPath, 'utf-8');
const parsed = JSON.parse(raw);
policy = deepMerge(DEFAULT_POLICY, parsed);
} catch {
// No policy file or invalid JSON — use defaults
policy = { ...DEFAULT_POLICY };
}
cache.set(root, policy);
return policy;
}
/**
* Get a specific policy value with fallback.
*
* v8.0.0: `.llm-security/policy.json` is the only configuration source for
* these keys. The `LLM_SECURITY_*` mode env-vars that used to take precedence
* were removed with the v7.3.0 deprecation runway — see the migration table in
* README.md. Note that this function has never read env itself; the override
* lived in the now-deleted `getPolicyValueWithEnvWarn` shim.
*
* @param {string} section - Policy section (e.g. 'injection', 'trifecta')
* @param {string} key - Key within section (e.g. 'mode', 'window_size')
* @param {*} defaultValue - Fallback if neither policy nor default has the value
* @param {string} [projectRoot] - Explicit root
* @returns {*}
*/
export function getPolicyValue(section, key, defaultValue, projectRoot) {
const policy = loadPolicy(projectRoot);
const sectionObj = policy[section];
// v7.8.3 (#26): a scalar section override in policy.json (e.g.
// {"injection": "block"}) survives deepMerge — guard before `in` so it
// falls back to the default instead of throwing a TypeError.
if (sectionObj && typeof sectionObj === 'object' && key in sectionObj) return sectionObj[key];
return defaultValue;
}
/**
* Get the full default policy (for documentation/example generation).
* @returns {object}
*/
export function getDefaultPolicy() {
return JSON.parse(JSON.stringify(DEFAULT_POLICY));
}
/**
* Reset the per-root policy cache (for testing only).
*/
export function _resetCacheForTest() {
cache.clear();
}