#!/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);
