Files
portal/tools/router-tool-gate.mjs
T
Дмитрий b4fb2cece9 feat(router-stage3): Task 6 — router-tool-gate PreToolUse hook (warn-only)
- tools/router-tool-gate.mjs: PreToolUse hook читает state из
  ~/.claude/runtime/router-state-<session>.json, решает block/proceed
  для Edit/Write/Bash (non-read-only). Escape hatch через HTML-тег
  <!-- routing: direct_justified=true reason="..." -->. Режим
  warn-only (default) / enforce через router-gate-mode.json.
- tools/router-tool-gate.test.mjs: 15 тестов GREEN (4 describe-блока:
  isReadOnlyBash / decodeRoutingTag / shouldBlock / decideDecision).
- CLI guard: fileURLToPath(import.meta.url) — Windows-cyrillic quirk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:05:00 +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() {
let input = '';
for await (const chunk of process.stdin) input += chunk;
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';
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }