#!/usr/bin/env node /** * PreToolUse hook — router tool gate. * Stage 3 of router discipline overhaul. * * Читает state из ~/.claude/runtime/router-state-.json (написан router-prehook). * Решает: block / proceed для tools Edit, Write, Bash (non-read-only). * * Escape hatch: в начале 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(//); if (!m) return null; return { directJustified: true, reason: m[1] }; } // §17 exempt set — task types that never trigger the gate (spec §4.4). // Continuation deliberately NOT in this list (D1): a continuation that // inherits a `feature`/`bugfix` classification gets the same enforcement as // the original prompt. // H (2026-05-26): 'unknown' added to NON_BLOCKING_TASK_TYPES. Brain-retro #6 // surfaced that the LLM classifier hits parse_null occasionally (Sonnet returns // JSON that parseClassifierResponse can't extract — prose wrapper or unexpected // shape), falling back to regex which assigns task_type=unknown. Blocking on // unknown is too strict — Bash/Edit gets stuck on routine work. G is the proper // fix (better parser); H is the workaround until G ships. const NON_BLOCKING_TASK_TYPES = ['conversation', 'micro', 'manual_override', 'unknown']; // brain-retro #7 C2 (2026-05-27): short ambiguous prompts. // Closes a leak in H bypass (unknown → exempt): when the LLM parser returns // parse_null on a short prompt, regex fallback assigns task_type=unknown and // the gate let it through. Reviewer (Opus 4.7) flagged 3/4 such cases as // mistake_should_not_start — agent improvised 15+ tool calls instead of // asking the user. Block them; long-prompt unknown still passes per H. const SHORT_AMBIGUOUS_LEN = 30; const SHORT_AMBIGUOUS_TYPES = new Set(['ambiguous', 'unknown']); function resolveTaskType(cls) { return cls?.task_type ?? cls?.taskType; } function resolveMode(options) { if (typeof options.mode === 'string') return options.mode; // Legacy fallback: warnOnly=false maps to enforce, otherwise warn-only. return options.warnOnly === false ? 'enforce' : 'warn-only'; } /** * §17 gate decision (spec §4.4, Phase 2 Task 13). * * @returns `false` when the tool call is allowed to proceed, or * `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' | 'short_ambiguous_block' }` * when the gate decides to block. * * Order of checks: * 1. mode off / warn-only → false (no enforcement) * 2. classification.no_skill_found === true → block(no_skill_found_block) * 3. C2 (brain-retro #7): skillInvoked=false, no direct_justified tag, * task_type ∈ {ambiguous, unknown}, prompt_length ≤ 30 * → block(short_ambiguous_block) — narrows H bypass on short improv. * 4. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set) * 5. skillInvokedThisTurn === true → false (skill already invoked) * 6. routing-tag direct_justified=true with reason → false (escape hatch) * 7. Bash + isReadOnlyBash(cmd) → false (read-only commands) * 8. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated) * 9. → block(direct_in_non_conversation) */ export function shouldBlock(tool, state, responseText, options = {}) { const mode = resolveMode(options); if (mode === 'off' || mode === 'warn-only') return false; const cls = state?.classification || {}; if (cls.no_skill_found === true) { return { block: true, reason: 'no_skill_found_block' }; } const taskType = resolveTaskType(cls); const tag = decodeRoutingTag(responseText); // C2: short ambiguous/unknown prompts. Check BEFORE NON_BLOCKING so 'unknown' // (which is in NON_BLOCKING for parse_null tolerance) is still caught when // the prompt is short. Respects skill-invoked + routing-tag escape hatches. const promptLen = state?.prompt_length; if ( typeof promptLen === 'number' && promptLen <= SHORT_AMBIGUOUS_LEN && SHORT_AMBIGUOUS_TYPES.has(taskType) && state?.skillInvokedThisTurn !== true && !tag?.directJustified ) { return { block: true, reason: 'short_ambiguous_block' }; } if (NON_BLOCKING_TASK_TYPES.includes(taskType)) return false; if (state?.skillInvokedThisTurn === true) return false; if (tag?.directJustified) return false; if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false; if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false; return { block: true, reason: 'direct_in_non_conversation' }; } export function decideDecision(tool, state, responseText, options = {}) { const cls = state?.classification || {}; const taskType = resolveTaskType(cls); const block = shouldBlock(tool, state, responseText, options); if (block && block.block) { const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)'; const recChain = cls.recommendedChain ?? cls.recommended_chain_id; const chainSuf = recChain ? ` (chain ${recChain})` : ''; let reasonText; if (block.reason === 'no_skill_found_block') { reasonText = `Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${recNode}.`; } else if (block.reason === 'short_ambiguous_block') { reasonText = `Короткий размытый промпт (${state?.prompt_length ?? '?'} симв, task_type=${taskType}). На таких — лучше уточнить через AskUserQuestion, чем угадывать. Если задача и правда тривиальна — начни ответ с .`; } else { reasonText = `Эта задача классифицирована как ${taskType}. Реестр рекомендует узел ${recNode}${chainSuf}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с с явным обоснованием.`; } return { decision: 'block', reason: reasonText, reason_code: block.reason }; } const mode = resolveMode(options); if ( mode === 'warn-only' && taskType && !NON_BLOCKING_TASK_TYPES.includes(taskType) && cls.no_skill_found !== true && !state?.skillInvokedThisTurn ) { const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)'; return { warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${recNode}.`, }; } 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')); if (data.mode === 'enforce') return 'enforce'; if (data.mode === 'off') return 'off'; return '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 responseText = ''; // PreToolUse event doesn't include response const bashCommand = (event.tool_input || {}).command || ''; const decision = decideDecision(tool, state, responseText, { mode, 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(); }