From 8ec320f40c182fc5e460adf8f907bea7aa21641c Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 10 Apr 2026 13:37:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(governance):=20add=20policy-as-code=20?= =?UTF-8?q?=E2=80=94=20.llm-security/policy.json=20for=20distributable=20h?= =?UTF-8?q?ook=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hooks/scripts/post-mcp-verify.mjs | 12 +- .../hooks/scripts/post-session-guard.mjs | 10 +- .../hooks/scripts/pre-bash-destructive.mjs | 7 + .../hooks/scripts/pre-edit-secrets.mjs | 6 + .../scripts/pre-install-supply-chain.mjs | 12 +- .../hooks/scripts/pre-prompt-inject-scan.mjs | 6 +- .../hooks/scripts/pre-write-pathguard.mjs | 11 ++ .../scanners/lib/policy-loader.mjs | 146 ++++++++++++++++++ .../tests/lib/policy-loader.test.mjs | 103 ++++++++++++ 9 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 plugins/llm-security/scanners/lib/policy-loader.mjs create mode 100644 plugins/llm-security/tests/lib/policy-loader.test.mjs diff --git a/plugins/llm-security/hooks/scripts/post-mcp-verify.mjs b/plugins/llm-security/hooks/scripts/post-mcp-verify.mjs index a304e05..a47147e 100644 --- a/plugins/llm-security/hooks/scripts/post-mcp-verify.mjs +++ b/plugins/llm-security/hooks/scripts/post-mcp-verify.mjs @@ -20,6 +20,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs'; import { checkDescriptionDrift } from '../../scanners/lib/mcp-description-cache.mjs'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Secret patterns — same set as pre-edit-secrets.mjs so any secret that @@ -73,7 +74,7 @@ const MIN_INJECTION_SCAN_LENGTH = 100; // a session. Warns when a single tool produces disproportionate output. // State file: ${os.tmpdir()}/llm-security-mcp-volume-${ppid}.json // --------------------------------------------------------------------------- -const MCP_TOOL_VOLUME_THRESHOLD = 100_000; // 100 KB from a single MCP tool +const MCP_TOOL_VOLUME_THRESHOLD = getPolicyValue('mcp', 'volume_threshold_bytes', 100_000); const VOLUME_STATE_FILE = join(tmpdir(), `llm-security-mcp-volume-${process.ppid}.json`); // --------------------------------------------------------------------------- @@ -199,6 +200,11 @@ if (!outputText.trim()) { const advisories = []; const isBash = toolName === 'Bash'; +// Policy: trusted MCP servers are exempt from volume tracking and drift checks +const trustedServers = new Set(getPolicyValue('mcp', 'trusted_servers', [])); +const mcpServerName = toolName.includes('mcp__') ? toolName.split('__')[1] : null; +const isTrustedMcp = mcpServerName && trustedServers.has(mcpServerName); + // ========================================================================= // Bash-specific checks: secrets, external URLs, large MCP output // These checks are only relevant for shell command output. @@ -322,7 +328,7 @@ if (isHtmlSource && outputText.length >= MIN_INJECTION_SCAN_LENGTH) { // Only relevant for MCP tools that provide a description in tool_input. // ========================================================================= const isMcpTool = toolName?.startsWith('mcp__'); -if (isMcpTool) { +if (isMcpTool && !isTrustedMcp) { const description = toolInput?.description || toolInput?.tool_description || ''; if (description && typeof description === 'string' && description.length > 10) { try { @@ -345,7 +351,7 @@ if (isMcpTool) { // Tracks cumulative output size per MCP tool within a session. Warns when // a single tool produces disproportionate output (>100 KB cumulative). // ========================================================================= -if (isMcpTool && outputText.length > 0) { +if (isMcpTool && !isTrustedMcp && outputText.length > 0) { const volState = loadVolumeState(); volState.volumes[toolName] = (volState.volumes[toolName] || 0) + outputText.length; const toolTotal = volState.volumes[toolName]; diff --git a/plugins/llm-security/hooks/scripts/post-session-guard.mjs b/plugins/llm-security/hooks/scripts/post-session-guard.mjs index bd66e9c..92344e9 100644 --- a/plugins/llm-security/hooks/scripts/post-session-guard.mjs +++ b/plugins/llm-security/hooks/scripts/post-session-guard.mjs @@ -43,18 +43,19 @@ import { createHash } from 'node:crypto'; import { extractMcpServer } from '../../scanners/lib/mcp-description-cache.mjs'; import { jensenShannonDivergence, buildDistribution } from '../../scanners/lib/distribution-stats.mjs'; import { writeAuditEvent } from '../../scanners/lib/audit-trail.mjs'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const WINDOW_SIZE = 20; +const WINDOW_SIZE = getPolicyValue('trifecta', 'window_size', 20); const STATE_PREFIX = 'llm-security-session-'; const STATE_DIR = tmpdir(); const CLEANUP_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours // Long-horizon monitoring (OpenAI Atlas, Dec 2025) -const LONG_HORIZON_WINDOW = 100; +const LONG_HORIZON_WINDOW = getPolicyValue('trifecta', 'long_horizon_window', 100); const SLOW_BURN_MIN_SPREAD = 50; const DRIFT_THRESHOLD = 0.25; const DRIFT_SAMPLE_SIZE = 20; @@ -62,8 +63,9 @@ const DRIFT_SAMPLE_SIZE = 20; // Sub-agent delegation tracking (DeepMind Agent Traps kat. 4, v5.0 S4) const DELEGATION_ESCALATION_WINDOW = 5; // calls after input_source -// Rule of Two enforcement mode: block | warn | off (default: warn) -const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || 'warn').toLowerCase(); +// Rule of Two enforcement mode: block | warn | off (env var takes precedence over policy) +const policyTrifectaMode = getPolicyValue('trifecta', 'mode', 'warn'); +const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || policyTrifectaMode).toLowerCase(); // Volume tracking thresholds (cumulative bytes per session) const VOLUME_THRESHOLDS = [ diff --git a/plugins/llm-security/hooks/scripts/pre-bash-destructive.mjs b/plugins/llm-security/hooks/scripts/pre-bash-destructive.mjs index 466ea76..423ab4c 100644 --- a/plugins/llm-security/hooks/scripts/pre-bash-destructive.mjs +++ b/plugins/llm-security/hooks/scripts/pre-bash-destructive.mjs @@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs'; import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // BLOCK rules — exit 2, command is not executed. @@ -78,6 +79,12 @@ const BLOCK_RULES = [ 'strings, which is a common code injection vector. Blocked. ' + 'Refactor to use explicit commands instead.', }, + // Policy-defined additional blocked patterns + ...getPolicyValue('destructive', 'additional_blocked', []).map(entry => ({ + name: entry.name || 'Custom blocked pattern', + pattern: new RegExp(entry.pattern), + description: entry.description || 'Blocked by policy.', + })), ]; // --------------------------------------------------------------------------- diff --git a/plugins/llm-security/hooks/scripts/pre-edit-secrets.mjs b/plugins/llm-security/hooks/scripts/pre-edit-secrets.mjs index cfcb1f6..93b665f 100644 --- a/plugins/llm-security/hooks/scripts/pre-edit-secrets.mjs +++ b/plugins/llm-security/hooks/scripts/pre-edit-secrets.mjs @@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs'; import { normalize } from 'node:path'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Secret detection patterns (union of global, kiur, llm-security, ms-ai-architect) @@ -32,6 +33,11 @@ const SECRET_PATTERNS = [ { name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i }, { name: 'Authorization header with token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ }, { name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i }, + // Policy-defined additional patterns + ...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({ + name: `Custom pattern ${i + 1}`, + pattern: new RegExp(p), + })), ]; // --------------------------------------------------------------------------- diff --git a/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs b/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs index beb5333..e8d8c07 100644 --- a/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs +++ b/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs @@ -28,6 +28,10 @@ import { queryOSV, extractOSVSeverity, } from '../../scanners/lib/supply-chain-data.mjs'; import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; + +// Policy-defined additional blocked packages (merged with built-in lists) +const POLICY_BLOCKED = new Set(getPolicyValue('supply_chain', 'additional_blocked_packages', [])); // =========================================================================== // Read stdin @@ -119,10 +123,10 @@ async function checkNpm() { for (const spec of packages) { const { name, version } = parseSpec(spec); - if (isCompromised(NPM_COMPROMISED, name, version)) { + if (isCompromised(NPM_COMPROMISED, name, version) || POLICY_BLOCKED.has(name)) { blocks.push( `COMPROMISED: ${name}${version ? '@' + version : ''}\n` + - ` Known supply chain attack. See: https://socket.dev/npm/package/${name}` + ` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : 'Known supply chain attack.'} See: https://socket.dev/npm/package/${name}` ); continue; } @@ -325,10 +329,10 @@ async function checkPip() { for (const spec of packages) { const { name, version } = parsePipSpec(spec); - if (isCompromised(PIP_COMPROMISED, name, version)) { + if (isCompromised(PIP_COMPROMISED, name, version) || POLICY_BLOCKED.has(name)) { blocks.push( `COMPROMISED: ${name} (PyPI)\n` + - ` Known malicious package (likely typosquat).\n` + + ` ${POLICY_BLOCKED.has(name) ? 'Blocked by policy.' : 'Known malicious package (likely typosquat).'}\n` + ` See: https://pypi.org/project/${name}/` ); continue; diff --git a/plugins/llm-security/hooks/scripts/pre-prompt-inject-scan.mjs b/plugins/llm-security/hooks/scripts/pre-prompt-inject-scan.mjs index d75ae37..635d050 100644 --- a/plugins/llm-security/hooks/scripts/pre-prompt-inject-scan.mjs +++ b/plugins/llm-security/hooks/scripts/pre-prompt-inject-scan.mjs @@ -21,14 +21,16 @@ import { readFileSync } from 'node:fs'; import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- -// Mode configuration +// Mode configuration (env var takes precedence over policy file) // --------------------------------------------------------------------------- const VALID_MODES = new Set(['block', 'warn', 'off']); +const policyMode = getPolicyValue('injection', 'mode', 'block'); const mode = VALID_MODES.has(process.env.LLM_SECURITY_INJECTION_MODE) ? process.env.LLM_SECURITY_INJECTION_MODE - : 'block'; + : VALID_MODES.has(policyMode) ? policyMode : 'block'; // Off mode: skip scanning entirely if (mode === 'off') { diff --git a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs index b998605..15f0d3a 100644 --- a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs +++ b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs @@ -11,6 +11,7 @@ import { readFileSync } from 'node:fs'; import { basename, normalize, resolve } from 'node:path'; +import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Sensitive path patterns — 8 categories @@ -68,6 +69,9 @@ const SETTINGS_FILES = [ 'settings.local.json', ]; +/** Category 9: Policy-defined additional protected paths */ +const POLICY_PATTERNS = getPolicyValue('pathguard', 'additional_protected', []).map(p => new RegExp(p)); + // --------------------------------------------------------------------------- // Path classification // --------------------------------------------------------------------------- @@ -142,6 +146,13 @@ function classifyPath(filePath) { } } + // Category 9: Policy-defined additional protected paths + for (const pat of POLICY_PATTERNS) { + if (pat.test(norm)) { + return { blocked: true, category: 'policy', reason: `Policy-protected path: ${norm}` }; + } + } + return { blocked: false, category: '', reason: '' }; } diff --git a/plugins/llm-security/scanners/lib/policy-loader.mjs b/plugins/llm-security/scanners/lib/policy-loader.mjs new file mode 100644 index 0000000..463c604 --- /dev/null +++ b/plugins/llm-security/scanners/lib/policy-loader.mjs @@ -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(); +} diff --git a/plugins/llm-security/tests/lib/policy-loader.test.mjs b/plugins/llm-security/tests/lib/policy-loader.test.mjs new file mode 100644 index 0000000..a1d2ebf --- /dev/null +++ b/plugins/llm-security/tests/lib/policy-loader.test.mjs @@ -0,0 +1,103 @@ +// policy-loader.test.mjs — Tests for policy-as-code loader + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadPolicy, getPolicyValue, getDefaultPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs'; + +const TEST_ROOT = join(tmpdir(), `llm-security-policy-test-${Date.now()}`); +const POLICY_DIR = join(TEST_ROOT, '.llm-security'); +const POLICY_FILE = join(POLICY_DIR, 'policy.json'); + +describe('policy-loader', () => { + beforeEach(() => { + _resetCacheForTest(); + mkdirSync(POLICY_DIR, { recursive: true }); + }); + + afterEach(() => { + _resetCacheForTest(); + try { rmSync(TEST_ROOT, { recursive: true }); } catch {} + }); + + it('returns defaults when no policy file exists', () => { + rmSync(POLICY_FILE, { force: true }); + const policy = loadPolicy(TEST_ROOT); + assert.equal(policy.version, '1.0'); + assert.equal(policy.injection.mode, 'block'); + assert.equal(policy.trifecta.mode, 'warn'); + assert.equal(policy.trifecta.window_size, 20); + }); + + it('reads and merges valid policy file', () => { + writeFileSync(POLICY_FILE, JSON.stringify({ + version: '1.0', + trifecta: { mode: 'off' }, + })); + const policy = loadPolicy(TEST_ROOT); + assert.equal(policy.trifecta.mode, 'off'); + // Other defaults preserved + assert.equal(policy.trifecta.window_size, 20); + assert.equal(policy.injection.mode, 'block'); + }); + + it('handles partial policy (deep merge preserves defaults)', () => { + writeFileSync(POLICY_FILE, JSON.stringify({ + secrets: { additional_patterns: ['CUSTOM_SECRET=\\w+'] }, + })); + const policy = loadPolicy(TEST_ROOT); + assert.deepEqual(policy.secrets.additional_patterns, ['CUSTOM_SECRET=\\w+']); + assert.deepEqual(policy.secrets.allowed_paths, []); // default preserved + }); + + it('caches policy per root', () => { + writeFileSync(POLICY_FILE, JSON.stringify({ trifecta: { mode: 'block' } })); + const p1 = loadPolicy(TEST_ROOT); + // Modify file — should still return cached + writeFileSync(POLICY_FILE, JSON.stringify({ trifecta: { mode: 'off' } })); + const p2 = loadPolicy(TEST_ROOT); + assert.equal(p1, p2); // same reference (cached) + assert.equal(p2.trifecta.mode, 'block'); // original value + }); + + it('getPolicyValue returns correct values', () => { + writeFileSync(POLICY_FILE, JSON.stringify({ + mcp: { volume_threshold_bytes: 500_000 }, + })); + const val = getPolicyValue('mcp', 'volume_threshold_bytes', 100_000, TEST_ROOT); + assert.equal(val, 500_000); + }); + + it('getPolicyValue returns default when key not in policy', () => { + writeFileSync(POLICY_FILE, JSON.stringify({ version: '1.0' })); + const val = getPolicyValue('mcp', 'nonexistent_key', 42, TEST_ROOT); + assert.equal(val, 42); + }); + + it('handles invalid JSON gracefully', () => { + writeFileSync(POLICY_FILE, 'not valid json!!!'); + const policy = loadPolicy(TEST_ROOT); + // Should return defaults without crashing + assert.equal(policy.version, '1.0'); + assert.equal(policy.injection.mode, 'block'); + }); + + it('getDefaultPolicy returns a copy', () => { + const d1 = getDefaultPolicy(); + const d2 = getDefaultPolicy(); + assert.deepEqual(d1, d2); + assert.notEqual(d1, d2); // different references + }); + + it('default policy matches existing hardcoded values', () => { + const defaults = getDefaultPolicy(); + // These must match the hardcoded values in hooks + assert.equal(defaults.injection.mode, 'block'); + assert.equal(defaults.trifecta.mode, 'warn'); + assert.equal(defaults.trifecta.window_size, 20); + assert.equal(defaults.trifecta.long_horizon_window, 100); + assert.equal(defaults.mcp.volume_threshold_bytes, 100_000); + }); +});