// mcp-description-cache.mjs — Cache MCP tool descriptions and detect drift. // Zero external dependencies. // // Purpose: // MCP servers can change tool descriptions between sessions (rug-pull, MCP05). // This module caches the first-seen description for each tool and alerts when // a subsequent invocation delivers a description that has drifted significantly // (Levenshtein distance > 10% of original length). // // v7.3.0 (E14): Adds a sticky baseline per tool so cumulative drift across // many small updates can be detected. Each entry now carries: // - description: latest description // - firstSeen / lastSeen: timestamps // - baseline: { description, seenAt } — immutable until clearBaseline() // - history: [{ description, seenAt, distance }] — last 10 drift events (FIFO) // Cumulative drift = levenshtein(current, baseline.description) / max(|current|, |baseline|) // When cumulative >= cumulative_drift_threshold (default 0.25), emit advisory. // Baseline survives TTL purge so slow-burn drift is preserved. // // Storage: ~/.cache/llm-security/mcp-descriptions.json // TTL: 7 days per entry — but entries with a baseline survive purge. // // OWASP: MCP05 (Tool Description Manipulation / Rug Pull) import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { levenshtein } from './string-utils.mjs'; import { getPolicyValue } from './policy-loader.mjs'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CACHE_DIR = join(homedir(), '.cache', 'llm-security'); const CACHE_FILE = join(CACHE_DIR, 'mcp-descriptions.json'); const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const DRIFT_THRESHOLD = 0.10; // 10% Levenshtein distance per-update const CUMULATIVE_DRIFT_THRESHOLD_DEFAULT = 0.25; // 25% baseline drift const HISTORY_CAP = 10; // --------------------------------------------------------------------------- // Cache structure (v7.3.0) // --------------------------------------------------------------------------- // { // "mcp__server__tool": { // "description": "latest description text", // "firstSeen": 1712345678000, // "lastSeen": 1712345678000, // "hash": "sha256-prefix (optional, for quick equality check)", // "baseline": { "description": "...", "seenAt": 1712345678000 }, // "history": [ // { "description": "...", "seenAt": 1712345678000, "distance": 12 } // ] // } // } /** * Resolve cache file path. Env-var override useful for testing the hook * without polluting the user's real cache. * @param {object} opts * @returns {string} */ function resolveCacheFile(opts) { if (opts.cacheFile) return opts.cacheFile; if (process.env.LLM_SECURITY_MCP_CACHE_FILE) return process.env.LLM_SECURITY_MCP_CACHE_FILE; return CACHE_FILE; } /** * Migrate a legacy v7.2.0 cache entry to the v7.3.0 shape. * Discriminator: presence of `history` array. v7.2.0 entries have neither * `baseline` nor `history`; v7.3.0 entries always have `history` (even if * empty). After `clearBaseline()` an entry has `history` but no `baseline`, * which is NOT a legacy entry and must NOT be re-seeded here — the next * `checkDescriptionDrift` call will seed baseline from the incoming * description instead. * Idempotent — running on a v7.3.0 entry is a no-op. * @param {object} entry * @returns {object} */ function migrateEntry(entry) { if (!Array.isArray(entry.history)) { // v7.2.0 → v7.3.0: seed baseline from current description and add history if (!entry.baseline) { entry.baseline = { description: entry.description, seenAt: entry.firstSeen, }; } entry.history = []; } return entry; } /** * Load the cache from disk. Purges entries older than TTL — but entries with * a `baseline` survive purge, so cumulative-drift detection persists across * the 7-day window. * Returns empty object if file doesn't exist or is corrupt. * Migrates v7.2.0 entries on the fly. * @param {object} [opts] - Options for testing * @param {string} [opts.cacheFile] - Override cache file path * @param {number} [opts.now] - Override current time * @returns {Record} */ export function loadCache(opts = {}) { const cacheFile = resolveCacheFile(opts); const now = opts.now ?? Date.now(); if (!existsSync(cacheFile)) return {}; try { const raw = readFileSync(cacheFile, 'utf-8'); const data = JSON.parse(raw); if (!data || typeof data !== 'object') return {}; const cleaned = {}; for (const [key, entry] of Object.entries(data)) { if (entry && typeof entry === 'object' && typeof entry.lastSeen === 'number') { const stale = now - entry.lastSeen > TTL_MS; if (!stale || entry.baseline) { cleaned[key] = migrateEntry(entry); } } } return cleaned; } catch { return {}; } } /** * Save the cache to disk. Creates the cache directory if needed. * @param {Record} cache * @param {object} [opts] * @param {string} [opts.cacheFile] - Override cache file path */ export function saveCache(cache, opts = {}) { const cacheFile = resolveCacheFile(opts); const dir = dirname(cacheFile); try { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(cacheFile, JSON.stringify(cache, null, 2), 'utf-8'); } catch { // Silently fail — drift detection is advisory, not critical } } /** * Resolve the cumulative-drift threshold (0..1). * Order: opts.cumulativeThreshold → policy.json (mcp.cumulative_drift_threshold) → default. * @param {object} opts * @returns {number} */ function resolveCumulativeThreshold(opts) { if (typeof opts.cumulativeThreshold === 'number') return opts.cumulativeThreshold; try { const v = getPolicyValue('mcp', 'cumulative_drift_threshold', CUMULATIVE_DRIFT_THRESHOLD_DEFAULT); return typeof v === 'number' ? v : CUMULATIVE_DRIFT_THRESHOLD_DEFAULT; } catch { return CUMULATIVE_DRIFT_THRESHOLD_DEFAULT; } } /** * Check a tool description against the cached version. * * First call for a tool: caches the description AND seeds the baseline, * returns no drift. * Subsequent calls: * - per-update drift: levenshtein(current, previous) / |previous| > 10% * - cumulative drift: levenshtein(current, baseline) / max(|current|, |baseline|) >= 25% * * @param {string} toolName - Full tool name (e.g. "mcp__tavily__tavily_search") * @param {string} description - Current tool description * @param {object} [opts] - Options for testing * @param {string} [opts.cacheFile] - Override cache file path * @param {number} [opts.now] - Override current time * @param {number} [opts.cumulativeThreshold] - Override cumulative threshold (0..1) * @returns {{ * drift: boolean, * detail: string|null, * distance: number, * threshold: number, * cached: string|null, * baselineDriftPct: number, * perUpdateDriftPct: number, * cumulative: { drifted: boolean, distance: number, threshold: number, detail: string|null, baseline: string|null } * }} */ export function checkDescriptionDrift(toolName, description, opts = {}) { const now = opts.now ?? Date.now(); const noDrift = { drift: false, detail: null, distance: 0, threshold: 0, cached: null, baselineDriftPct: 0, perUpdateDriftPct: 0, cumulative: { drifted: false, distance: 0, threshold: 0, detail: null, baseline: null }, }; if (!toolName || !description || typeof description !== 'string') { return noDrift; } const cumulativeThreshold = resolveCumulativeThreshold(opts); const cache = loadCache(opts); const existing = cache[toolName]; if (!existing) { // First time seeing this tool — cache it and seed the baseline cache[toolName] = { description, firstSeen: now, lastSeen: now, baseline: { description, seenAt: now }, history: [], }; saveCache(cache, opts); return { ...noDrift, threshold: DRIFT_THRESHOLD, cumulative: { drifted: false, distance: 0, threshold: cumulativeThreshold, detail: null, baseline: description }, }; } // Defensive — entries from loadCache are already migrated, but be paranoid migrateEntry(existing); // Baseline was explicitly cleared (clearBaseline) — re-seed from the // incoming description so the next legitimate MCP version becomes the // new baseline. if (!existing.baseline) { existing.description = description; existing.lastSeen = now; existing.baseline = { description, seenAt: now }; saveCache(cache, opts); return { ...noDrift, threshold: DRIFT_THRESHOLD, cached: null, cumulative: { drifted: false, distance: 0, threshold: cumulativeThreshold, detail: null, baseline: description }, }; } // Update lastSeen existing.lastSeen = now; // Quick equality check if (existing.description === description) { saveCache(cache, opts); return { ...noDrift, threshold: DRIFT_THRESHOLD, cached: existing.description, cumulative: { drifted: false, distance: 0, threshold: cumulativeThreshold, detail: null, baseline: existing.baseline.description, }, }; } // Per-update Levenshtein distance (vs previous description) const perDist = levenshtein(existing.description, description); const perBaseLen = Math.max(existing.description.length, 1); const perRatio = perDist / perBaseLen; const perDrifted = perRatio > DRIFT_THRESHOLD; // Cumulative Levenshtein distance (vs baseline) const cumDist = levenshtein(existing.baseline.description, description); const cumDenom = Math.max(existing.baseline.description.length, description.length, 1); const cumRatio = cumDist / cumDenom; const cumDrifted = cumRatio >= cumulativeThreshold; // Push event into history (FIFO, capped) existing.history.push({ description, seenAt: now, distance: perDist }); if (existing.history.length > HISTORY_CAP) { existing.history.splice(0, existing.history.length - HISTORY_CAP); } const cachedDesc = existing.description; // Update current description (baseline stays put) existing.description = description; saveCache(cache, opts); let perDetail = null; if (perDrifted) { const pct = Math.round(perRatio * 100); perDetail = `Tool "${toolName}" description changed by ${pct}% (${perDist} edits / ${perBaseLen} chars). ` + `Threshold: ${Math.round(DRIFT_THRESHOLD * 100)}%. This may indicate a rug-pull attack (OWASP MCP05).`; } let cumDetail = null; if (cumDrifted) { const pct = Math.round(cumRatio * 100); cumDetail = `Tool "${toolName}" cumulative description drift ${pct}% from baseline (${cumDist} edits). ` + `Threshold: ${Math.round(cumulativeThreshold * 100)}%. ` + `Slow-burn rug-pull may evade per-update detection (OWASP MCP05).`; } return { drift: perDrifted, detail: perDetail, distance: perDist, threshold: DRIFT_THRESHOLD, cached: cachedDesc, baselineDriftPct: cumRatio, perUpdateDriftPct: perRatio, cumulative: { drifted: cumDrifted, distance: cumDist, threshold: cumulativeThreshold, detail: cumDetail, baseline: existing.baseline.description, }, }; } /** * Extract MCP server name from a tool name. * Convention: mcp____ * @param {string} toolName * @returns {string|null} */ export function extractMcpServer(toolName) { if (!toolName?.startsWith('mcp__')) return null; const parts = toolName.split('__'); // mcp__server__tool → parts = ['mcp', 'server', 'tool'] return parts.length >= 3 ? parts[1] : null; } /** * Clear the entire cache (for testing). * @param {object} [opts] * @param {string} [opts.cacheFile] - Override cache file path */ export function clearCache(opts = {}) { saveCache({}, opts); } /** * Clear the baseline slot for one tool (or all tools when toolName omitted). * Preserves description, firstSeen, lastSeen, and history. After clearing, * the next checkDescriptionDrift call will re-seed the baseline from the * current description. * * @param {string} [toolName] - Specific tool, or omit to clear all baselines * @param {object} [opts] * @param {string} [opts.cacheFile] - Override cache file path * @returns {{ cleared: number, tools: string[] }} */ export function clearBaseline(toolName, opts = {}) { const cache = loadCache(opts); const cleared = []; if (toolName) { if (cache[toolName] && cache[toolName].baseline) { delete cache[toolName].baseline; cleared.push(toolName); } } else { for (const [key, entry] of Object.entries(cache)) { if (entry && entry.baseline) { delete entry.baseline; cleared.push(key); } } } saveCache(cache, opts); return { cleared: cleared.length, tools: cleared }; } /** * Read-only baseline listing (for the reset CLI's --list mode). * @param {object} [opts] * @returns {Array<{ tool: string, baseline: string, seenAt: number, lastSeen: number, history: number }>} */ export function listBaselines(opts = {}) { const cache = loadCache(opts); const out = []; for (const [tool, entry] of Object.entries(cache)) { if (entry && entry.baseline) { out.push({ tool, baseline: entry.baseline.description, seenAt: entry.baseline.seenAt, lastSeen: entry.lastSeen, history: Array.isArray(entry.history) ? entry.history.length : 0, }); } } return out; } // --------------------------------------------------------------------------- // Exported constants (for testing) // --------------------------------------------------------------------------- export { TTL_MS, DRIFT_THRESHOLD, CUMULATIVE_DRIFT_THRESHOLD_DEFAULT, HISTORY_CAP, CACHE_DIR, CACHE_FILE, };