// 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: [], }, }); // Cache loaded policy per project root const cache = new Map(); // Module-scoped Set of env-var names already warned about — dedupes to one // stderr line per env-var per process, regardless of how many call-sites // invoke getPolicyValueWithEnvWarn for the same name. const _warnedEnvVars = new Set(); /** * 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. * Environment variables ALWAYS take precedence over policy file values. * * @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]; if (sectionObj && key in sectionObj) return sectionObj[key]; return defaultValue; } /** * Resolve a policy value with an overlapping env-var, emitting a one-time * stderr deprecation warning when both the env-var AND the policy.json key * are explicitly set. * * Resolution order (env-wins is unchanged from getPolicyValue contract): * 1. If LLM_SECURITY_DEPRECATION_QUIET=1, suppress warning logic entirely * and return env-value (if defined) else policy-value. * 2. Otherwise, if env-var is set AND policy-value differs from * defaultValue (heuristic: user wrote the key in policy.json), * emit one stderr warning per envVarName per process. * 3. Return env-value if defined, else policy-value. * * Why "differs from defaultValue" rather than parsing the raw policy file: * loadPolicy() deep-merges DEFAULT_POLICY so `key in policy[section]` is * always true. Comparing the resolved value to the caller's defaultValue * is a reliable proxy for "user explicitly overrode this in policy.json" * because callers pass defaults that match DEFAULT_POLICY. * * @param {string} section - Policy section (e.g. 'injection', 'trifecta') * @param {string} key - Key within section (e.g. 'mode') * @param {string} envVarName - Overlapping env-var (e.g. 'LLM_SECURITY_INJECTION_MODE') * @param {*} defaultValue - Hardcoded default (must match DEFAULT_POLICY value) * @param {string} [projectRoot] - Explicit root * @returns {*} */ export function getPolicyValueWithEnvWarn(section, key, envVarName, defaultValue, projectRoot) { const envValue = process.env[envVarName]; if (process.env.LLM_SECURITY_DEPRECATION_QUIET === '1') { if (envValue !== undefined) return envValue; return getPolicyValue(section, key, defaultValue, projectRoot); } const policyValue = getPolicyValue(section, key, defaultValue, projectRoot); if (envValue !== undefined && policyValue !== defaultValue) { if (!_warnedEnvVars.has(envVarName)) { _warnedEnvVars.add(envVarName); process.stderr.write( `[llm-security] Deprecation: env-var ${envVarName} will be removed in v8.0.0; ` + `policy.json key ${section}.${key} also set — env wins for now. ` + `Suppress with LLM_SECURITY_DEPRECATION_QUIET=1.\n` ); } } if (envValue !== undefined) return envValue; return policyValue; } /** * Get the full default policy (for documentation/example generation). * @returns {object} */ export function getDefaultPolicy() { return JSON.parse(JSON.stringify(DEFAULT_POLICY)); } /** * Reset cache and warning dedup state (for testing only). */ export function _resetCacheForTest() { cache.clear(); _warnedEnvVars.clear(); }