d221ba499d
Аудит М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 уже гарантирует).
85 lines
4.7 KiB
JavaScript
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();
|