165ff3a859
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
6.0 KiB
JavaScript
116 lines
6.0 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { decide } from './enforce-mcp-classification.mjs';
|
||
|
||
describe('enforce-mcp-classification decide()', () => {
|
||
it('allows non-mcp tools (no-op)', () => {
|
||
expect(decide({ toolName: 'Bash', toolInput: { command: 'ls' } }).block).toBe(false);
|
||
});
|
||
it('blocks an unknown mcp tool (default-deny)', () => {
|
||
const r = decide({ toolName: 'mcp__unknown__doSomething', toolInput: {} });
|
||
expect(r.block).toBe(true);
|
||
expect(r.reason).toMatch(/not in gate-config classification/);
|
||
});
|
||
});
|
||
|
||
// 7.3 (Блок 4.3) — скан ИСХОДЯЩЕГО (egress): даже разрешённый классификатором MCP-вызов не
|
||
// должен выносить наружу секреты (общий secret-scan) или использовать exfil-схемы
|
||
// (data:base64 / file:// / IP-литерал URL) или раздутый payload. fail-close на egress.
|
||
import { scanEgress } from './enforce-mcp-classification.mjs';
|
||
|
||
describe('scanEgress (7.3): скан исходящего MCP-payload на exfil', () => {
|
||
it('секрет в аргументах (AWS) → block', () => {
|
||
const r = scanEgress({ key: 'AKIAIOSFODNN7EXAMPLE' });
|
||
expect(r.block).toBe(true);
|
||
expect(r.reason).toMatch(/секрет/i);
|
||
});
|
||
it('data:base64 URI → block (exfil-вектор)', () => {
|
||
expect(scanEgress({ url: 'data:text/plain;base64,SGVsbG8=' }).block).toBe(true);
|
||
});
|
||
it('file:// схема → block', () => {
|
||
expect(scanEgress({ src: 'file:///etc/passwd' }).block).toBe(true);
|
||
});
|
||
it('IP-литерал URL → block (обход DNS/allowlist)', () => {
|
||
expect(scanEgress({ url: 'http://203.0.113.5/collect' }).block).toBe(true);
|
||
});
|
||
it('раздутый payload → block', () => {
|
||
expect(scanEgress({ blob: 'x'.repeat(50) }, { maxBytes: 20 }).block).toBe(true);
|
||
});
|
||
it('чистые аргументы → не block', () => {
|
||
expect(scanEgress({ key: 'user:42:profile' }).block).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('decide 7.3: egress-скан даже на разрешённом классификатором MCP-вызове', () => {
|
||
it('read_only mcp + секрет в аргументах → block (egress)', () => {
|
||
const r = decide({ toolName: 'mcp__redis__get', toolInput: { key: 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' } });
|
||
expect(r.block).toBe(true);
|
||
expect(r.reason).toMatch(/egress/i);
|
||
});
|
||
it('read_only mcp + чистые аргументы → allow', () => {
|
||
expect(decide({ toolName: 'mcp__redis__get', toolInput: { key: 'session:abc' } }).block).toBe(false);
|
||
});
|
||
});
|
||
|
||
// enforce-mcp-classification.mjs — egress/verdict escape (M6 Пакет 6)
|
||
import { canonicalAction } from './escape-grant.mjs';
|
||
describe('decide — egress/verdict escape снимается floor_escape (M6)', () => {
|
||
const now = 1000;
|
||
it('egress-блок (read_only + секрет) снимается совпавшим floor_escape', () => {
|
||
const input = { key: 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' };
|
||
const action = canonicalAction('mcp__redis__get', input);
|
||
const r = decide({ toolName: 'mcp__redis__get', toolInput: input,
|
||
escapeGrants: [{ action, ts: now - 5 }], escapeConsumed: [], now });
|
||
expect(r.block).toBe(false);
|
||
});
|
||
it('egress-блок без пропуска остаётся', () => {
|
||
const r = decide({ toolName: 'mcp__redis__get', toolInput: { key: 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' },
|
||
escapeGrants: [], escapeConsumed: [], now });
|
||
expect(r.block).toBe(true);
|
||
});
|
||
it('verdict-блок (unknown mcp) снимается совпавшим floor_escape', () => {
|
||
const input = {};
|
||
const action = canonicalAction('mcp__unknown__do', input);
|
||
const r = decide({ toolName: 'mcp__unknown__do', toolInput: input,
|
||
escapeGrants: [{ action, ts: now - 5 }], escapeConsumed: [], now });
|
||
expect(r.block).toBe(false);
|
||
});
|
||
it('погашенный пропуск (one-shot) → block остаётся', () => {
|
||
const input = {};
|
||
const action = canonicalAction('mcp__unknown__do', input);
|
||
const r = decide({ toolName: 'mcp__unknown__do', toolInput: input,
|
||
escapeGrants: [{ action, ts: now - 5 }], escapeConsumed: [{ action, ts: now - 5 }], now });
|
||
expect(r.block).toBe(true);
|
||
});
|
||
});
|
||
|
||
// enforce-mcp-classification.mjs — G-5 точный токен в блок-сообщении egress/verdict (M6 FIX-1)
|
||
describe('enforce-mcp-classification G-5 egress/verdict токен (M6 FIX-1)', () => {
|
||
const now = 1000;
|
||
it('verdict-блок (unknown mcp) выводит точный FLOOR-ESCAPE токен', () => {
|
||
const input = {};
|
||
const action = canonicalAction('mcp__unknown__do', input);
|
||
const r = decide({ toolName: 'mcp__unknown__do', toolInput: input, escapeGrants: [], escapeConsumed: [], now });
|
||
expect(r.block).toBe(true);
|
||
expect(r.reason).toContain(`FLOOR-ESCAPE: ${action}`);
|
||
});
|
||
it('egress-блок выводит точный FLOOR-ESCAPE токен', () => {
|
||
const input = { key: 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' };
|
||
const action = canonicalAction('mcp__redis__get', input);
|
||
const r = decide({ toolName: 'mcp__redis__get', toolInput: input, escapeGrants: [], escapeConsumed: [], now });
|
||
expect(r.block).toBe(true);
|
||
expect(r.reason).toContain(`FLOOR-ESCAPE: ${action}`);
|
||
});
|
||
});
|
||
|
||
// Task 7 wiring — decide() прокидывает config urlWhitelist в classifyMcpTool (config-seam).
|
||
describe('decide — config-seam urlWhitelist (Task 7 wiring)', () => {
|
||
it('пустой urlWhitelist → даже liderra.ru блокируется (fail-CLOSED)', () => {
|
||
const r = decide({ toolName: 'mcp__playwright__browser_navigate', toolInput: { url: 'https://liderra.ru/x' }, urlWhitelist: [] });
|
||
expect(r.block).toBe(true);
|
||
});
|
||
it('urlWhitelist с доменом → тот же домен разрешён', () => {
|
||
const r = decide({ toolName: 'mcp__playwright__browser_navigate', toolInput: { url: 'https://example.com/x' }, urlWhitelist: ['example.com'] });
|
||
expect(r.block).toBe(false);
|
||
});
|
||
});
|