Files
portal/tools/router-tool-gate.mjs
T
Дмитрий c7e02eeac9 feat(router): подключить UTF-8 helper к трём хукам (stage 3 follow-up 1)
router-prehook, router-stop-gate, router-tool-gate теперь читают stdin
через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно
доходит до Anthropic API и в state-файл — никаких mojibake типа
'посмотри'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:36:14 +03:00

113 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* PreToolUse hook — router tool gate.
* Stage 3 of router discipline overhaul.
*
* Читает state из ~/.claude/runtime/router-state-<session>.json (написан router-prehook).
* Решает: block / proceed для tools Edit, Write, Bash (non-read-only).
*
* Escape hatch: <!-- routing: direct_justified=true reason="..." --> в начале response пропускает.
*
* Mode: warn-only (только stderr) или enforce (decision: block).
* Mode читается из ~/.claude/runtime/router-gate-mode.json {"mode": "warn-only"|"enforce"}.
* По умолчанию warn-only (первая неделя), потом ручной переключатель.
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const READ_ONLY_BASH_PATTERNS = [
/^\s*ls(\s|$)/, /^\s*cat\s/, /^\s*head\s/, /^\s*tail\s/, /^\s*wc\s/,
/^\s*grep\s/, /^\s*find\s.*-print/, /^\s*pwd\s*$/,
/^\s*git\s+(status|log|show|diff|rev-parse|branch|ls-tree|ls-remote|remote\s+show|tag|fetch)/,
/^\s*node\s.*--check/, /^\s*npx\s+vitest\s+run/, /^\s*node\s+tools\/[\w-]+\.mjs\s+/,
];
export function isReadOnlyBash(command) {
if (!command) return false;
return READ_ONLY_BASH_PATTERNS.some((re) => re.test(command));
}
export function decodeRoutingTag(responseText) {
if (!responseText) return null;
const m = String(responseText).match(/<!--\s*routing:\s*direct_justified=true\s+reason=["']([^"']+)["']\s*-->/);
if (!m) return null;
return { directJustified: true, reason: m[1] };
}
export function shouldBlock(tool, state, responseText, options = {}) {
const warnOnly = options.warnOnly !== false; // default true
if (warnOnly) return false;
if (!state.enforcementRequired) return false;
if (state.skillInvokedThisTurn) return false;
if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;
const tag = decodeRoutingTag(responseText);
if (tag && tag.directJustified) return false;
return true;
}
export function decideDecision(tool, state, responseText, options = {}) {
const cls = state.classification || {};
if (shouldBlock(tool, state, responseText, options)) {
const recommendedNode = cls.recommendedNode || '(unknown)';
const recommendedChain = cls.recommendedChain ? ` (chain ${cls.recommendedChain})` : '';
return {
decision: 'block',
reason: `Эта задача классифицирована как ${cls.taskType}. Реестр рекомендует узел ${recommendedNode}${recommendedChain}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`,
};
}
if (options.warnOnly && state.enforcementRequired && !state.skillInvokedThisTurn) {
return {
warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${cls.recommendedNode}.`,
};
}
return {};
}
function gateMode() {
const path = join(homedir(), '.claude', 'runtime', 'router-gate-mode.json');
if (!existsSync(path)) return 'warn-only';
try {
const data = JSON.parse(readFileSync(path, 'utf-8'));
return data.mode === 'enforce' ? 'enforce' : 'warn-only';
} catch { return 'warn-only'; }
}
function readState(sessionId) {
const path = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
if (!existsSync(path)) return null;
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
}
async function main() {
const input = await readStdinAsUtf8(process.stdin);
const event = JSON.parse(input || '{}');
const sessionId = event.session_id || 'unknown';
const tool = event.tool_name;
const state = readState(sessionId);
if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }
const mode = gateMode();
const warnOnly = mode === 'warn-only';
const responseText = ''; // PreToolUse event doesn't include response
const bashCommand = (event.tool_input || {}).command || '';
const decision = decideDecision(tool, state, responseText, { warnOnly, bashCommand });
if (decision.warning) process.stderr.write(decision.warning + '\n');
process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
process.exit(0);
}
// CLI guard — Windows-cyrillic quirk: use fileURLToPath(import.meta.url)
import { fileURLToPath } from 'url';
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }