Files
portal/tools/enforce-mcp-classification.mjs
T
Дмитрий d221ba499d fix(m6): аудит-правки — G-5 egress токен, единый findOpenGrant, escape-журнал, уникальный id снимка
Аудит М6 (audit-context-building + sharp-edges + корректность; комплекс М1–М6), 4 практичных фикса (TDD):
- FIX-1: enforce-mcp-classification печатает точный FLOOR-ESCAPE токен в egress/verdict-блоке (G-5 для egress).
- FIX-2: escape-grant.findOpenGrant — единый предикат свежести для open и consume (гасит ИМЕННО открывший грант; чинит утечку one-shot при дублях/future-ts).
- FIX-3: enforce-supreme-gate.runGate — best-effort журнал escape (escape:true), указатель не двигается, сбой журнала не блокирует.
- FIX-4: enforce-snapshot — уникальный дефолтный id снимка (ts-pid-счётчик) против ms-коллизии refs/floor-snapshots.

Регрессия tools-only 2843 passed + 2 skip (+9, 0 регрессий). FIX-5 (подпись гранта) сознательно не делали (нулевая защита без ключа; protected-path уже гарантирует).
2026-06-07 19:43:05 +03:00

85 lines
4.7 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';
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.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, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
const name = String(toolName || '');
if (!name.startsWith('mcp__')) return { block: false, reason: null };
const verdict = classifyMcpTool(name, toolInput || {}, {});
// М6: сквозной аварийный выход владельца — снимает verdict/egress-блок при точном совпадении канон-строки.
const action = canonicalAction(name, toolInput || {});
const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now);
if (!verdict) return { block: false, reason: null };
if (verdict.decision === 'block' || verdict.decision === 'ask') {
if (escaped()) return { block: false, reason: 'mcp: разрешено аварийным выходом (floor_escape)' };
// G-5: блок-сообщение несёт точную канон-строку — владелец вставляет её дословно в AskUser как FLOOR-ESCAPE.
return { block: true, reason: `${verdict.reason || `${name} requires approval (decision=${verdict.decision})`}; FLOOR-ESCAPE: ${action}` };
}
// 7.3: классификатор разрешил — но проверяем, что наружу не уходит секрет/exfil-схема/раздутый payload.
const egress = scanEgress(toolInput || {});
if (egress.block) {
if (escaped()) return { block: false, reason: 'egress: разрешено аварийным выходом (floor_escape)' };
return { block: true, reason: `egress: ${egress.reason}; FLOOR-ESCAPE: ${action}` };
}
return { block: false, reason: null };
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
const sess = (event && event.session_id) || 'unknown';
const r = decide({ toolName: event.tool_name, toolInput: event.tool_input,
escapeGrants: loadFloorEscapes(sess), escapeConsumed: loadConsumed(sess) });
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();