BREAKING CHANGE: the four LLM_SECURITY_* configuration env-vars deprecated in v7.3.0 are removed. .llm-security/policy.json is now the only source: LLM_SECURITY_INJECTION_MODE -> injection.mode LLM_SECURITY_TRIFECTA_MODE -> trifecta.mode LLM_SECURITY_ESCALATION_WINDOW -> trifecta.escalation_window LLM_SECURITY_AUDIT_LOG -> audit.log_path LLM_SECURITY_DEPRECATION_QUIET -> dies with the mechanism it silenced Setting a removed var is now inert - it does not warn, and it does not configure. Env-vars with no policy equivalent (PRECOMPACT_MODE, PRECOMPACT_MAX_BYTES, UPDATE_CHECK, MCP_CACHE_FILE, IDE_ROOTS) are unaffected. getPolicyValueWithEnvWarn and its one-shot stderr warning are deleted from policy-loader.mjs, along with the module-scoped warned-var Set. The four call sites collapse to getPolicyValue. getPolicyValue's JSDoc claimed "environment variables ALWAYS take precedence" - it never read env itself, so that line described the shim, and it is corrected rather than deleted. User-facing hook strings that advertised a removed var as the escape hatch now name the policy key instead: the inject-scan block reason, its warn-mode note, both escalation-window advisories, and the trifecta block message. A blocked user following the old text would have set a var that does nothing. Tests. tests/lib/v8-env-removal.test.mjs is the regression gate and was written failing first (8 of 12 red before the change). It pins the NEGATIVE - setting a removed var does not alter the outcome - because that is the half that rots silently: a re-introduced process.env read would leave every migrated positive test green, since those configure through policy.json and never set the var at all. One assertion walks hooks/scripts and scanners for `process.env.<removed>` so the re-introduction is caught structurally, not only behaviourally. The 44 env-driven test occurrences (18 inject-scan, 13+4 session-guard, 9 audit-trail) migrate to a throwaway .llm-security/policy.json via a new runHookWithPolicy helper in hook-helper.mjs; audit-trail runs in-process, so it supplies the same policy through CLAUDE_PROJECT_ROOT. The D3 mechanism tests in policy-loader.test.mjs are deleted with the mechanism. Suite 2039 tests, 2037 pass (+12 gate, -7 D3 mechanism). The 2 failures are the known parallel-load timing flakes (pre-compact-scan size-cap, pre-install-supply-chain F-3); both green when run isolated. Remaining in Phase 3: posture-scanner TRIFECTA_MODE heuristic, riskScoreV1 removal, ghost-var cleanup, docs + migration note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
204 lines
6.2 KiB
JavaScript
204 lines
6.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 } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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).
|
|
*
|
|
* @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;
|
|
|
|
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();
|
|
}
|