165ff3a859
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
5.1 KiB
JavaScript
92 lines
5.1 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';
|
||
import { logGuardBlock } from './guard-block-log.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, urlWhitelist, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||
const name = String(toolName || '');
|
||
if (!name.startsWith('mcp__')) return { block: false, reason: null };
|
||
const verdict = classifyMcpTool(name, toolInput || {}, urlWhitelist !== undefined ? { urlWhitelist } : {});
|
||
// М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';
|
||
let urlWhitelist = [];
|
||
try {
|
||
const { loadConfig } = await import('./brain-config.mjs');
|
||
urlWhitelist = loadConfig(process.cwd()).project_url_whitelist;
|
||
} catch { urlWhitelist = []; }
|
||
const r = decide({ toolName: event.tool_name, toolInput: event.tool_input, urlWhitelist,
|
||
escapeGrants: loadFloorEscapes(sess), escapeConsumed: loadConsumed(sess) });
|
||
if (r.block) {
|
||
logGuardBlock(event, 'М5 Egress-страж', r.reason);
|
||
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();
|