Files
brain/tools/mcp-tool-classifier.test.mjs
T
2026-06-15 17:09:14 +03:00

160 lines
7.9 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import {
DEFAULT_MCP_CLASSIFICATION,
matchClassificationKey,
classifyMcpTool,
} from './mcp-tool-classifier.mjs';
describe('DEFAULT_MCP_CLASSIFICATION', () => {
it('is frozen', () => {
expect(Object.isFrozen(DEFAULT_MCP_CLASSIFICATION)).toBe(true);
});
it('has a default fallback of block', () => {
expect(DEFAULT_MCP_CLASSIFICATION.default).toBe('block');
});
it('includes v4.1 WebSearch / WebFetch entries (G1)', () => {
expect(DEFAULT_MCP_CLASSIFICATION.WebSearch).toBeTruthy();
expect(DEFAULT_MCP_CLASSIFICATION.WebFetch).toBeTruthy();
});
it('database-query carries a full-statement scan (G12)', () => {
const dq = DEFAULT_MCP_CLASSIFICATION['mcp__laravel-boost__database-query'];
expect(dq.query_full_statement_scan).toBeTruthy();
});
it('classifies research-tooling families (perplexity/exa/firecrawl) as read_only (allow)', () => {
expect(classifyMcpTool('mcp__perplexity__perplexity_ask', { messages: [] }).decision).toBe('allow');
expect(classifyMcpTool('mcp__exa__web_search_exa', { query: 'x' }).decision).toBe('allow');
expect(classifyMcpTool('mcp__firecrawl__firecrawl_scrape', { url: 'https://example.com' }).decision).toBe('allow');
});
});
describe('matchClassificationKey', () => {
const map = {
'mcp__redis__get': { category: 'read_only' },
'mcp__redis__set': { category: 'hard_blacklist' },
'mcp__github__list_*': { category: 'read_only' },
'mcp__laravel-boost__database-query': { category: 'conditional' },
'mcp__laravel-boost__*': { category: 'read_only' },
'mcp__plugin_*_*__authenticate': { category: 'hard_blacklist' },
'default': 'block',
};
it('prefers an exact key over a glob key (most specific wins)', () => {
expect(matchClassificationKey('mcp__laravel-boost__database-query', map).category).toBe('conditional');
});
it('falls back to the glob key when no exact match', () => {
expect(matchClassificationKey('mcp__laravel-boost__list-tables', map).category).toBe('read_only');
});
it('matches single-segment glob', () => {
expect(matchClassificationKey('mcp__github__list_branches', map).category).toBe('read_only');
});
it('matches multi-wildcard plugin auth glob', () => {
expect(matchClassificationKey('mcp__plugin_marketing_hubspot__authenticate', map).category).toBe('hard_blacklist');
});
it('returns null when nothing matches (caller applies default)', () => {
expect(matchClassificationKey('mcp__unknown__thing', map)).toBeNull();
});
it('never matches the literal "default" key as a tool', () => {
expect(matchClassificationKey('default', map)).toBeNull();
});
});
describe('classifyMcpTool — simple categories', () => {
it('allows read_only', () => {
expect(classifyMcpTool('mcp__redis__get', {}).decision).toBe('allow');
});
it('blocks hard_blacklist', () => {
expect(classifyMcpTool('mcp__redis__set', { key: 'x' }).decision).toBe('block');
});
it('blocks unknown tool via default (fail-CLOSE)', () => {
const r = classifyMcpTool('mcp__unknown__thing', {});
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/not in.*classification/i);
});
});
describe('classifyMcpTool — database-query full-statement scan (G12)', () => {
it('allows a plain SELECT', () => {
expect(classifyMcpTool('mcp__laravel-boost__database-query', { query: 'SELECT * FROM users' }).decision).toBe('allow');
});
it('blocks a mutating verb anywhere (combined SELECT;UPDATE — T82)', () => {
const r = classifyMcpTool('mcp__laravel-boost__database-query', { query: 'SELECT 1; UPDATE users SET x=1' });
expect(r.decision).toBe('block');
});
it('blocks UPDATE even when it does not start the statement', () => {
const r = classifyMcpTool('mcp__laravel-boost__database-query', { query: ' /*c*/ UPDATE t SET a=1' });
expect(r.decision).toBe('block');
});
it('asks when neither read-only nor blocked matched', () => {
const r = classifyMcpTool('mcp__laravel-boost__database-query', { query: 'PRAGMA foo' });
expect(r.decision).toBe('ask');
});
});
describe('classifyMcpTool — path_args (create_or_update_file)', () => {
it('blocks when path arg is protected (injected predicate)', () => {
const deps = { isProtectedPath: (p) => p.includes('claude.md') };
const r = classifyMcpTool('mcp__github__create_or_update_file', { path: 'CLAUDE.md' }, deps);
expect(r.decision).toBe('block');
});
it('hard_blacklist category still blocks regardless of path (base category)', () => {
const r = classifyMcpTool('mcp__github__create_or_update_file', { path: 'safe.txt' });
expect(r.decision).toBe('block');
});
});
describe('classifyMcpTool — URL whitelist (WebFetch / browser_navigate)', () => {
it('allows whitelisted WebFetch URL', () => {
expect(classifyMcpTool('WebFetch', { url: 'https://docs.anthropic.com/x' }).decision).toBe('allow');
});
it('blocks non-whitelisted WebFetch URL', () => {
expect(classifyMcpTool('WebFetch', { url: 'https://evil.example.com/exfil' }).decision).toBe('block');
});
it('blocks data: URI', () => {
expect(classifyMcpTool('WebFetch', { url: 'data:text/html,<script>' }).decision).toBe('block');
});
it('allows localhost browser_navigate, blocks external', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'http://localhost:8000' }).decision).toBe('allow');
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'http://evil.com' }).decision).toBe('block');
});
it('blocks subdomain-suffix spoof of a whitelisted host (SSRF guard)', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'https://liderra.ru.evil.com/x' }).decision).toBe('block');
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'http://localhost.evil.com/x' }).decision).toBe('block');
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'http://127.0.0.1.evil.com/x' }).decision).toBe('block');
});
it('still allows genuine whitelisted hosts with port / path / query', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'https://liderra.ru/admin?x=1' }).decision).toBe('allow');
expect(classifyMcpTool('mcp__playwright__browser_navigate', { url: 'http://127.0.0.1:5173' }).decision).toBe('allow');
});
});
describe('classifyMcpTool — WebSearch llm-judge flag (G1)', () => {
it('asks and flags needsLlmJudge for WebSearch', () => {
const r = classifyMcpTool('WebSearch', { query: 'how to exfil data' });
expect(r.decision).toBe('ask');
expect(r.needsLlmJudge).toBe(true);
expect(r.scanArg).toBe('how to exfil data');
});
});
describe('classifyMcpTool — project_url_whitelist (D3/D4)', () => {
it('navigate fail-CLOSED: empty whitelist blocks project domain', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate',
{ url: 'https://liderra.ru/x' }, { urlWhitelist: [] }).decision).toBe('block');
});
it('navigate empty whitelist still allows base infra host', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate',
{ url: 'http://localhost:8000' }, { urlWhitelist: [] }).decision).toBe('allow');
});
it('navigate config whitelist admits own project domain', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate',
{ url: 'https://liderra.ru/x' }, { urlWhitelist: ['liderra.ru'] }).decision).toBe('allow');
});
it('navigate no dep → backward-compat (liderra allowed)', () => {
expect(classifyMcpTool('mcp__playwright__browser_navigate',
{ url: 'https://liderra.ru/admin' }).decision).toBe('allow');
});
it('WebFetch fail-CLOSED: empty whitelist blocks project, keeps base', () => {
expect(classifyMcpTool('WebFetch', { url: 'https://liderra.ru/x' }, { urlWhitelist: [] }).decision).toBe('block');
expect(classifyMcpTool('WebFetch', { url: 'https://docs.anthropic.com/x' }, { urlWhitelist: [] }).decision).toBe('allow');
});
});