Wave C step C1: extend the MCP description cache schema with a sticky baseline slot per tool and a rolling history array (last 10 drift events). Cumulative drift = levenshtein(current, baseline) / max(|current|, |baseline|); emits a separate signal when ratio >= mcp.cumulative_drift_threshold (default 0.25). Per-update drift logic and threshold unchanged. - loadCache(): TTL purge now skips entries with a baseline, preserving cumulative-drift detection across the 7-day window. v7.2.0 entries (no history field) are migrated on read by seeding baseline from the current description and adding an empty history array. Entries with history but no baseline (post-clearBaseline) are NOT re-seeded. - checkDescriptionDrift(): when an entry exists with history but no baseline (i.e. baseline was cleared), the next call re-seeds baseline from the incoming description so the legitimate next version becomes the new baseline. - clearBaseline(toolName?): removes baseline for one tool or all tools. Preserves description / firstSeen / lastSeen / history. - listBaselines(): read-only listing for the upcoming reset CLI. - LLM_SECURITY_MCP_CACHE_FILE env var override for end-to-end testing. - New policy key mcp.cumulative_drift_threshold (default 0.25). Tests: 23 new unit tests; existing 10 still pass. Docs deferred: CLAUDE.md update lands in C3 alongside the new /security mcp-baseline-reset command. C2 adds the hooks-table footer note. Combined wave docs match plan §"Wave C — Touch" list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
164 lines
4.4 KiB
JavaScript
164 lines
4.4 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,
|
|
},
|
|
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();
|
|
|
|
/**
|
|
* 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();
|
|
}
|