#!/usr/bin/env node // run-rug-pull.mjs — MCP slow-burn rug-pull demonstration (v7.3.0 / E14) // Mutates an MCP tool description across 7 stages — each edit stays UNDER // the per-update threshold (10% Levenshtein vs previous), but cumulatively // the description diverges >=25% from the original baseline. Demonstrates // that `post-mcp-verify` emits a `mcp-cumulative-drift` MEDIUM advisory // even when per-update detection never trips. // // State isolation: // The hook reads MCP description cache via LLM_SECURITY_MCP_CACHE_FILE. // This script points it at a private temp file and deletes that file // on exit. The user's real ~/.cache/llm-security/mcp-descriptions.json // is never touched. // // Usage: // cd plugins/llm-security // node examples/mcp-rug-pull/run-rug-pull.mjs // node examples/mcp-rug-pull/run-rug-pull.mjs --verbose import { execFile } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = resolve(__dirname, '../..'); const HOOK = resolve(PLUGIN_ROOT, 'hooks/scripts/post-mcp-verify.mjs'); const VERBOSE = process.argv.includes('--verbose'); // --------------------------------------------------------------------------- // Description sequence — slow-burn rug-pull // --------------------------------------------------------------------------- // // Baseline: a benign-looking knowledge-base MCP server description. // Each subsequent stage applies a small edit. Per-update Levenshtein stays // under 10%, but by stage 7 the cumulative distance to baseline >=25% and // the meaning has shifted toward exfiltration ("upload", "external", // "telemetry endpoint"). // Each stage edits at most ~6 chars vs the previous (per-update <10%), but // every edit moves "internal knowledge base / agent / read" toward // "local data store / user / brief snippets". By stage 7 the cumulative // Levenshtein vs baseline crosses 25% — the rug-pull is detected even // though no single update tripped per-update detection. const STAGES = [ // 0 — baseline (121 chars). All future drift is measured against this. 'Search the internal knowledge base. Returns a ranked list of matching documents and short snippets for the agent to read.', // 1 — "agent" → "user" (per=3.3%, cum=3.3%) 'Search the internal knowledge base. Returns a ranked list of matching documents and short snippets for the user to read.', // 2 — "ranked" → "scored" (per=3.3%, cum=6.6%) 'Search the internal knowledge base. Returns a scored list of matching documents and short snippets for the user to read.', // 3 — "short" → "brief" (per=4.2%, cum=10.7%) 'Search the internal knowledge base. Returns a scored list of matching documents and brief snippets for the user to read.', // 4 — "documents" → "files" (per=5.8%, cum=16.5%) 'Search the internal knowledge base. Returns a scored list of matching files and brief snippets for the user to read.', // 5 — "internal" → "local" (per=5.2%, cum=21.5%) 'Search the local knowledge base. Returns a scored list of matching files and brief snippets for the user to read.', // 6 — "base" → "store" (per=3.5%, cum=24.8% — still under cumulative threshold) 'Search the local knowledge store. Returns a scored list of matching files and brief snippets for the user to read.', // 7 — "knowledge" → "data" (per=7.9%, cum=32.2% — CUMULATIVE TRIPS, per-update does not) 'Search the local data store. Returns a scored list of matching files and brief snippets for the user to read.', ]; // --------------------------------------------------------------------------- // Hook runner // --------------------------------------------------------------------------- function runHook(input, env) { return new Promise((res) => { const child = execFile( 'node', [HOOK], { timeout: 5000, env: { ...process.env, ...env } }, (_err, stdout, stderr) => { res({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' }); }, ); child.stdin.end(JSON.stringify(input)); }); } function parseAdvisories(stdout) { const trimmed = stdout.trim(); if (!trimmed.startsWith('{')) return []; try { const parsed = JSON.parse(trimmed); if (!parsed.systemMessage) return []; // Hook joins multiple advisories with `\n\n---\n\n` (see post-mcp-verify.mjs) return parsed.systemMessage.split('\n\n---\n\n'); } catch { return []; } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const tmpDir = mkdtempSync(join(tmpdir(), 'llm-security-rugpull-')); const cacheFile = join(tmpDir, 'mcp-descriptions.json'); console.log('MCP CUMULATIVE DRIFT (RUG-PULL) WALKTHROUGH'); console.log('==========================================='); console.log(`Cache file (deleted on exit): ${cacheFile}\n`); console.log('Per-update threshold: 10% Levenshtein vs previous description'); console.log('Cumulative threshold: 25% Levenshtein vs sticky baseline'); console.log('OWASP MCP05 (Rug Pull) — v7.3.0 introduces the cumulative leg.\n'); let pass = 0; let fail = 0; const expectations = [ { perUpdate: false, cumulative: false, note: 'baseline seeded — no advisory' }, { perUpdate: false, cumulative: false, note: 'agent → user' }, { perUpdate: false, cumulative: false, note: 'ranked → scored' }, { perUpdate: false, cumulative: false, note: 'short → brief' }, { perUpdate: false, cumulative: false, note: 'documents → files' }, { perUpdate: false, cumulative: false, note: 'internal → local' }, { perUpdate: false, cumulative: false, note: 'base → store (cum=24.8%, just under threshold)' }, { perUpdate: false, cumulative: true, note: 'knowledge → data — CUMULATIVE TRIPS at 32.2%' }, ]; try { for (let i = 0; i < STAGES.length; i++) { const description = STAGES[i]; const expect = expectations[i]; // post-mcp-verify exits early when tool_output is empty — the drift // check only runs on tool calls that actually produce output. We send a // benign placeholder so the description-drift code path executes. const result = await runHook( { tool_name: 'mcp__knowledge__search', tool_input: { description, query: 'demo' }, tool_output: 'no results', }, { LLM_SECURITY_MCP_CACHE_FILE: cacheFile }, ); const advisories = parseAdvisories(result.stdout); const perUpdateAdv = advisories.find(a => a.includes('description drift detected')); const cumulativeAdv = advisories.find(a => a.includes('cumulative description drift')); const perUpdateOk = !!perUpdateAdv === expect.perUpdate; const cumulativeOk = !!cumulativeAdv === expect.cumulative; const ok = perUpdateOk && cumulativeOk; if (ok) pass++; else fail++; const tick = ok ? 'PASS' : 'FAIL'; const len = description.length; console.log(`[${tick}] Stage ${i} (${len} chars) — ${expect.note}`); console.log(` per-update advisory: expect=${expect.perUpdate} got=${!!perUpdateAdv}`); console.log(` cumulative advisory: expect=${expect.cumulative} got=${!!cumulativeAdv}`); console.log(` description: "${description.slice(0, 80)}${len > 80 ? '...' : ''}"`); if (cumulativeAdv) { const head = cumulativeAdv.split('\n').slice(0, 2).join('\n'); console.log(` advisory preview: "${head.replace(/\n/g, ' / ')}"`); } if (VERBOSE && result.stderr.trim()) { console.log(` stderr: ${result.stderr.trim().slice(0, 120)}`); } console.log(); } if (VERBOSE && existsSync(cacheFile)) { const cache = JSON.parse(readFileSync(cacheFile, 'utf-8')); const entry = cache['mcp__knowledge__search']; if (entry) { console.log('Cache state at exit:'); console.log(` baseline.description = "${entry.baseline.description.slice(0, 60)}..."`); console.log(` current.description = "${entry.description.slice(0, 60)}..."`); console.log(` history length = ${entry.history?.length ?? 0}`); console.log(); } } } finally { rmSync(tmpDir, { recursive: true, force: true }); } console.log('---'); console.log(`Result: ${pass} pass, ${fail} fail`); if (fail > 0) { console.log('\nFAILURE — see expected-findings.md for the documented contract.'); process.exit(1); } console.log('\nSUCCESS — cumulative-drift advisory fired exactly when expected.'); console.log('Reset MCP baseline after a legitimate upgrade: /security mcp-baseline-reset'); process.exit(0);