ktg-plugin-marketplace/plugins/llm-security/tests/scanners/mcp-baseline-reset.test.mjs
Kjell Tore Guttormsen 001df2ebe8 feat(commands): E14 part 3 — /security mcp-baseline-reset slash command
Wave C step C3: closes E14 with the user-facing reset command.

After a legitimate MCP server upgrade the sticky baseline (added in C1)
becomes a stale "what the tool used to say" anchor and every subsequent
post-mcp-verify advisory will re-flag the change. /security mcp-baseline-reset
lets the user acknowledge the upgrade so the next call seeds a fresh
baseline.

New files:
- scanners/mcp-baseline-reset.mjs — small CLI wrapper around clearBaseline /
  listBaselines. Modes: --list (read-only), --target <name>, no-args (all).
  Outputs JSON summary on stdout. Exit 0 always (idempotent).
- commands/mcp-baseline-reset.md — dispatcher following mcp-inspect.md
  shape. Frontmatter: name=security:mcp-baseline-reset, sonnet model,
  Read/Bash/AskUserQuestion tools. 4-step body (list -> confirm scope
  -> execute -> confirm result).
- tests/scanners/mcp-baseline-reset.test.mjs — 10 CLI tests across
  --list, --target, clear-all, idempotency, history preservation, and
  bare-positional sugar.

Updated:
- commands/security.md — new row in commands table after mcp-inspect.
- CLAUDE.md — new commands-table row + new v7.3.0 narrative section
  describing the baseline schema, cumulative-drift detection, reset
  semantics, and the LLM_SECURITY_MCP_CACHE_FILE override.
- Plugin README.md — new MCP-baseline-reset row in commands table,
  scanner count 12 standalone -> 13 standalone, new "MCP Description
  Drift (E14, v7.3.0)" subsection explaining the sticky baseline,
  cumulative threshold, reset semantics, and env-var override.
- Root marketplace README.md — scanner count 22 -> 23 (10 orchestrated +
  13 standalone), command count 19 -> 20, test count 1511 -> 1768.

Wave C complete: 1738 -> 1768 tests (+30 across C1/C2/C3). Per plan,
Wave C does NOT bump the plugin version — that lands at the wave-bundle
release. The advisory text in post-mcp-verify already references the
new command path so the user has a ready remediation step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 16:49:01 +02:00

244 lines
9 KiB
JavaScript

// mcp-baseline-reset.test.mjs — CLI tests for scanners/mcp-baseline-reset.mjs
// Zero external dependencies: node:test + node:assert + child_process.execFile.
//
// LLM_SECURITY_MCP_CACHE_FILE controls the cache path so the test does not
// pollute the user's real ~/.cache/llm-security/mcp-descriptions.json.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
const SCRIPT = resolve(import.meta.dirname, '../../scanners/mcp-baseline-reset.mjs');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function runCli(args, env) {
return new Promise((res) => {
execFile(
'node',
[SCRIPT, ...args],
{ env: { ...process.env, ...env }, timeout: 5000 },
(err, stdout, stderr) => {
res({
code: err && typeof err.code === 'number' ? err.code : 0,
stdout: stdout || '',
stderr: stderr || '',
});
},
);
});
}
function makeTmpCache() {
const dir = mkdtempSync(join(tmpdir(), 'baseline-reset-test-'));
const cacheFile = join(dir, 'mcp-descriptions.json');
return { dir, cacheFile };
}
function cleanup(dir) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
function seedCache(cacheFile, entries) {
writeFileSync(cacheFile, JSON.stringify(entries, null, 2), 'utf-8');
}
function parseJson(stdout) {
return JSON.parse(stdout.trim());
}
const NOW = Date.now();
function makeEntry(desc, opts = {}) {
return {
description: desc,
firstSeen: NOW - 10000,
lastSeen: NOW,
baseline: opts.noBaseline ? undefined : { description: desc, seenAt: NOW - 10000 },
history: opts.history || [],
};
}
// ---------------------------------------------------------------------------
// --list mode
// ---------------------------------------------------------------------------
describe('mcp-baseline-reset CLI — --list mode', () => {
it('returns mode=list with empty baselines on empty cache', async () => {
const { dir, cacheFile } = makeTmpCache();
const result = await runCli(['--list'], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.mode, 'list');
assert.equal(json.count, 0);
assert.deepEqual(json.baselines, []);
cleanup(dir);
});
it('lists all entries with baseline metadata', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
'mcp__beta__t': makeEntry('Beta description text long enough'),
});
const result = await runCli(['--list'], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.mode, 'list');
assert.equal(json.count, 2);
const tools = json.baselines.map((b) => b.tool).sort();
assert.deepEqual(tools, ['mcp__alpha__t', 'mcp__beta__t']);
for (const b of json.baselines) {
assert.ok(typeof b.baseline_excerpt === 'string');
assert.ok(typeof b.seen_at === 'number');
assert.ok(typeof b.last_seen === 'number');
assert.ok(typeof b.history_events === 'number');
}
cleanup(dir);
});
it('--list does not mutate the cache', async () => {
const { dir, cacheFile } = makeTmpCache();
const before = {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
};
seedCache(cacheFile, before);
await runCli(['--list'], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
const after = JSON.parse(readFileSync(cacheFile, 'utf-8'));
assert.ok(after['mcp__alpha__t'].baseline, 'baseline preserved by --list');
cleanup(dir);
});
});
// ---------------------------------------------------------------------------
// --target mode (single tool)
// ---------------------------------------------------------------------------
describe('mcp-baseline-reset CLI — --target mode', () => {
it('clears one named baseline and reports it', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
'mcp__beta__t': makeEntry('Beta description text long enough'),
});
const result = await runCli(
['--target', 'mcp__alpha__t'],
{ LLM_SECURITY_MCP_CACHE_FILE: cacheFile },
);
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.mode, 'reset');
assert.equal(json.cleared, 1);
assert.deepEqual(json.tools, ['mcp__alpha__t']);
assert.equal(json.remaining, 1, 'beta baseline still present');
// Verify on disk
const after = JSON.parse(readFileSync(cacheFile, 'utf-8'));
assert.equal(after['mcp__alpha__t'].baseline, undefined, 'alpha baseline cleared');
assert.ok(after['mcp__beta__t'].baseline, 'beta baseline preserved');
cleanup(dir);
});
it('idempotent — clearing nonexistent target reports 0 cleared', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
});
const result = await runCli(
['--target', 'mcp__no_such__tool'],
{ LLM_SECURITY_MCP_CACHE_FILE: cacheFile },
);
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.cleared, 0);
assert.deepEqual(json.tools, []);
assert.equal(json.remaining, 1, 'unrelated baseline untouched');
cleanup(dir);
});
});
// ---------------------------------------------------------------------------
// Clear-all mode (no args)
// ---------------------------------------------------------------------------
describe('mcp-baseline-reset CLI — clear-all mode', () => {
it('with no args, clears all baselines', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
'mcp__beta__t': makeEntry('Beta description text long enough'),
'mcp__gamma__t': makeEntry('Gamma description text long enough'),
});
const result = await runCli([], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.mode, 'reset');
assert.equal(json.cleared, 3);
assert.equal(json.remaining, 0);
assert.equal(json.tools.length, 3);
const after = JSON.parse(readFileSync(cacheFile, 'utf-8'));
for (const key of ['mcp__alpha__t', 'mcp__beta__t', 'mcp__gamma__t']) {
assert.equal(after[key].baseline, undefined);
}
cleanup(dir);
});
it('idempotent — clear-all on empty cache returns 0', async () => {
const { dir, cacheFile } = makeTmpCache();
const result = await runCli([], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.cleared, 0);
assert.equal(json.remaining, 0);
cleanup(dir);
});
it('preserves description and history after clear', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough', {
history: [{ description: 'older', seenAt: NOW - 5000, distance: 4 }],
}),
});
await runCli([], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
const after = JSON.parse(readFileSync(cacheFile, 'utf-8'));
const entry = after['mcp__alpha__t'];
assert.equal(entry.baseline, undefined);
assert.equal(entry.description, 'Alpha description text long enough', 'description preserved');
assert.ok(typeof entry.firstSeen === 'number');
assert.equal(entry.history.length, 1, 'history preserved');
cleanup(dir);
});
});
// ---------------------------------------------------------------------------
// Help / unknown args
// ---------------------------------------------------------------------------
describe('mcp-baseline-reset CLI — misc', () => {
it('--help prints usage and exits 0', async () => {
const result = await runCli(['--help'], {});
assert.equal(result.code, 0);
assert.ok(/Usage:/i.test(result.stdout));
});
it('bare positional argument is treated as --target', async () => {
const { dir, cacheFile } = makeTmpCache();
seedCache(cacheFile, {
'mcp__alpha__t': makeEntry('Alpha description text long enough'),
'mcp__beta__t': makeEntry('Beta description text long enough'),
});
const result = await runCli(['mcp__alpha__t'], { LLM_SECURITY_MCP_CACHE_FILE: cacheFile });
assert.equal(result.code, 0);
const json = parseJson(result.stdout);
assert.equal(json.cleared, 1);
assert.deepEqual(json.tools, ['mcp__alpha__t']);
cleanup(dir);
});
});