/** * PreToolUse(Read) wrapper — path-deny for Read tool. * Router-gate v4 emergency fix (Smoke 5 2026-05-30). * * Spec §3.1 declared transcript JSONL hard-deny but Read tool had NO * path-protection — controller could Read ~/.claude/projects/*.jsonl * (parent context exfil from other sessions). Same for runtime artifacts, * .env, normative files. * * Reuses DEFAULT_PROTECTED_PATTERNS from shell-content-rules.mjs. * Fail-CLOSE on internal error (security default). */ import { fileURLToPath } from 'url'; import { readStdin, parseEventJson, exitDecision, } from './enforce-hook-helpers.mjs'; import { defaultPathNormalize, isProtectedPath, READ_DENY_PATTERNS } from './shell-content-rules.mjs'; import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; export function decide({ toolName, filePath, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { if (toolName !== 'Read') return { block: false, reason: null }; const fp = String(filePath || ''); // Path-deny only. Narrow READ_DENY_PATTERNS (not the full DEFAULT_PROTECTED_PATTERNS): // Read of CLAUDE.md / normative docs / memory has no exfil value and must stay allowed for // the claude-md-management / memory-sync workflow. Only genuine Read-exfil targets — // transcripts, runtime, settings, secrets — are blocked. The full protected-list still // guards Bash/PowerShell read and Write (over-block fix 2026-05-31). // F-1 (аудит 2026-06-07): контент-скан выдачи убран — был МЁРТВ в проде (PreToolUse(Read)-хук, // main() не передавал content; контента до чтения нет). Реальный exfil (исходящий payload) // закрыт живым enforce-mcp-classification.scanEgress. decide() гейтит строго по пути. if (fp && isProtectedPath(fp, defaultPathNormalize, READ_DENY_PATTERNS)) { // M7 Фаза 2 (правило 7в): escape владельца снимает Read-блок защищённого пути // (страж обязан чтить аварийный выход, как пол/стена/egress). Канон-строка действия — // тот же binding-ключ, что владелец вставляет в AskUser-escape. const action = canonicalAction('Read', { file_path: fp }); if (escapeGrantOpen(action, escapeGrants, escapeConsumed, now)) { return { block: false, reason: null }; } return { block: true, reason: `path «${defaultPathNormalize(fp)}» protected against Read (§3.1 transcript/runtime/secrets hard-deny); 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'; const r = decide({ toolName: event.tool_name, filePath: event.tool_input?.file_path || event.tool_input?.filePath, escapeGrants: loadFloorEscapes(sess), escapeConsumed: loadConsumed(sess), }); if (r.block) { logGuardBlock(event, 'М5 Read-страж', r.reason); return exitDecision({ block: true, message: `[read-path-deny] ${r.reason}` }); } return exitDecision({ block: false }); } catch { return exitDecision({ block: true, message: '[read-path-deny] внутренняя ошибка — fail-CLOSE' }); } } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();