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:
Kjell Tore Guttormsen 2026-04-10 13:37:02 +02:00
commit 8ec320f40c
9 changed files with 300 additions and 13 deletions

View file

@ -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];

View file

@ -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 = [

View file

@ -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.',
})),
];
// ---------------------------------------------------------------------------

View file

@ -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),
})),
];
// ---------------------------------------------------------------------------

View file

@ -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;

View file

@ -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') {

View file

@ -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: '' };
}

View 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();
}

View file

@ -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);
});
});