feat(governance): add policy-as-code — .llm-security/policy.json for distributable hook configuration
New policy-loader.mjs reads .llm-security/policy.json with deep-merge against defaults that exactly match existing hardcoded values. Integrated into all 7 hooks: - pre-prompt-inject-scan: injection.mode (env var still takes precedence) - post-session-guard: trifecta.mode, window_size, long_horizon_window - pre-edit-secrets: secrets.additional_patterns - pre-bash-destructive: destructive.additional_blocked - pre-write-pathguard: pathguard.additional_protected - pre-install-supply-chain: supply_chain.additional_blocked_packages - post-mcp-verify: mcp.volume_threshold_bytes, mcp.trusted_servers Backward compatible: no policy file = identical behavior to v5.1.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0439e0f650
commit
8ec320f40c
9 changed files with 300 additions and 13 deletions
146
plugins/llm-security/scanners/lib/policy-loader.mjs
Normal file
146
plugins/llm-security/scanners/lib/policy-loader.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
audit: {
|
||||
log_path: null,
|
||||
events: ['trifecta', 'injection', 'secrets', 'destructive'],
|
||||
},
|
||||
});
|
||||
|
||||
// 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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full default policy (for documentation/example generation).
|
||||
* @returns {object}
|
||||
*/
|
||||
export function getDefaultPolicy() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_POLICY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cache (for testing only).
|
||||
*/
|
||||
export function _resetCacheForTest() {
|
||||
cache.clear();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue