Files
portal/tools/enforce-mcp-classification.mjs
T
Дмитрий ecca24f451 feat(m5): 7.3 mcp-classification +egress-скан исходящего (Пакет 7, Блок 4.3)
scanEgress(toolInput): даже разрешённый классификатором MCP-вызов не должен выносить
наружу — block при: секрет в аргументах (общий secret-scan, анти-дрейф с 7.1), data:base64
URI / file:// схема / IP-литерал URL (обход DNS/allowlist), раздутый payload (> maxBytes).
exfil-схемы узко-таргетированы (data:...;base64, и file://) — обычный текст не ловят.
decide: после allow-классификации запускает scanEgress → block с reason «egress: …».

+8 тестов. enforce-mcp-classification 10/10. Дыра «что уходит наружу» закрыта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:15:49 +03:00

74 lines
3.6 KiB
JavaScript

/**
* PreToolUse(mcp__*) wrapper for tools/mcp-tool-classifier.mjs.
* Router-gate v4 spec §5.3 + v4.1 G1/G12.
*
* Classifier categorises MCP tool calls; default-deny on unknown.
* 'ask' decision is treated as block (controller must seek explicit approval).
* Fail-CLOSE on internal error.
*/
import { fileURLToPath } from 'url';
import {
readStdin,
parseEventJson,
exitDecision,
} from './enforce-hook-helpers.mjs';
import { classifyMcpTool } from './mcp-tool-classifier.mjs';
import { scanSecrets } from './secret-scan.mjs';
// 7.3 (Блок 4.3) exfil-схемы в исходящих: base64-данные / локальный файл / IP-литерал URL
// (обход DNS/allowlist). Узко-таргетированы (data:...;base64, и file://) — не ловят обычный текст.
const DATA_URI_RE = /\bdata:[^\s;,]*;base64,/i;
const FILE_URI_RE = /\bfile:\/\//i;
const IP_URL_RE = /\bhttps?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/i;
function serializeArgs(toolInput) {
try { return JSON.stringify(toolInput); } catch { return String(toolInput); }
}
/**
* 7.3: скан ИСХОДЯЩЕГО MCP-payload на exfil. Секреты (общий secret-scan) / data:base64 / file:// /
* IP-литерал URL / раздутый payload → block. fail-close на egress: даже разрешённый классификатором
* вызов не должен выносить наружу секрет или использовать exfil-схему.
*/
export function scanEgress(toolInput, { maxBytes = 100000 } = {}) {
const text = serializeArgs(toolInput);
const sec = scanSecrets(text);
if (sec.found) return { block: true, reason: `секрет в исходящих аргументах (${sec.hits.join(', ')})` };
if (DATA_URI_RE.test(text)) return { block: true, reason: 'data:base64 URI в исходящих (exfil-вектор)' };
if (FILE_URI_RE.test(text)) return { block: true, reason: 'file:// схема в исходящих' };
if (IP_URL_RE.test(text)) return { block: true, reason: 'IP-литерал URL в исходящих (обход DNS/allowlist)' };
if (text.length > maxBytes) return { block: true, reason: `раздутый исходящий payload (${text.length} > ${maxBytes} байт)` };
return { block: false };
}
export function decide({ toolName, toolInput }) {
const name = String(toolName || '');
if (!name.startsWith('mcp__')) return { block: false, reason: null };
const verdict = classifyMcpTool(name, toolInput || {}, {});
if (!verdict) return { block: false, reason: null };
if (verdict.decision === 'block' || verdict.decision === 'ask') {
return { block: true, reason: verdict.reason || `${name} requires approval (decision=${verdict.decision})` };
}
// 7.3: классификатор разрешил — но проверяем, что наружу не уходит секрет/exfil-схема/раздутый payload.
const egress = scanEgress(toolInput || {});
if (egress.block) return { block: true, reason: `egress: ${egress.reason}` };
return { block: false, reason: null };
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
const r = decide({ toolName: event.tool_name, toolInput: event.tool_input });
if (r.block) {
return exitDecision({ block: true, message: `[mcp-classification] ${r.reason}` });
}
return exitDecision({ block: false });
} catch {
return exitDecision({ block: true, message: '[mcp-classification] внутренняя ошибка — fail-CLOSE' });
}
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();