c7e02eeac9
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>
113 lines
4.6 KiB
JavaScript
113 lines
4.6 KiB
JavaScript
#!/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(); }
|