222 lines
6.9 KiB
JavaScript
222 lines
6.9 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: [],
|
|
},
|
|
});
|
|
|
|
// 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();
|
|
}
|