#!/usr/bin/env node /** * floor-decide (Машина 5, Блок 1) — чистое ядро НЕСУЩЕГО ПОЛА: вето-до-плана на * необратимое. Срабатывает ПЕРВЫМ (обёртка enforce-floor зовёт его до seed/observe/ * проверки членства в плане). Валидная печать плана для floor-набора нерелевантна. * * Два инспектируемых канала (§2-точность): * - Bash: classifyDestructive(...).floor — whole-string (ловит chaining + quote-hardened * FLOOR_RE из Пакета 1) И посегментно через tokenizeBash (unquoted-токены нейтрализуют * кавычки; ловит floor-сегмент за читающим: `cat x && git push --force`). * - Write/Edit/NotebookEdit: путь записи через resolving pathNormalize → запись в * ~/.claude/runtime ИЛИ секрет (.env/ключ/cert) → block. fail-CLOSED (normalize * бросил → block; в отличие от lenient enforce-runtime-write-deny — пол строже). * * Дверь владельца Δ1 (узкая): необратимая Bash-операция проходит ТОЛЬКО при наличии * свежего (≤5 мин) одобрения ТОЧНОЙ команды в approve_git_operation. Читается READ-ONLY * (F5: floor НЕ потребляет — реальный потребитель shell-content::loadApprovedGitOps тоже * read-only+window, гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime, * пишет PostToolUse-хук на реальный AskUser). Полный escape — в М6. * * NB (audit-context 2026-06-07): writer approval-записей сейчас НЕ подписывает (HMAC * signApprovalRecord существует, но не подключён) → интегрити двери держится protected-path * side-channel, не подписью. force-push дополнительно жёстко блокируется shell-content * GIT_HARD_PATTERNS даже approved (дверь для force-push мута — защита-в-глубину; дверь * реально значима для floor-only migrate:fresh/reset/refresh/db:wipe). Подпись подключится * в writer'е позже (P10-c) — тогда дверь усилится без смены контракта. * * Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из * per-session файла; unknown-сессия → пустой approvedGitOps → дверь закрыта → block. * fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor. */ import { classifyDestructive } from './classify-destructive.mjs'; import { tokenizeBash } from './bash-tokenizer.mjs'; import { pathNormalize } from './path-normalization.mjs'; const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i; const SECRET_PATH_RE = [ /(^|\/)\.env(\.[\w-]+)?$/i, // .env / .env.local / .env.production /\.(pem|key|p12|pfx)$/i, // приватные ключи / сертификаты /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i, // ssh-ключи ]; const OBSERVE_TOOLS = new Set(['Read', 'Grep', 'Glob']); // только смотрят — floor записи не касается // B4-выравнивание: писатели несут путь под разными именами полей (как extractPath/actionOf). const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri']; const APPROVE_WINDOW_MS = 5 * 60 * 1000; function extractWritePath(input) { if (!input || typeof input !== 'object') return ''; for (const f of PATH_FIELDS) { if (typeof input[f] === 'string' && input[f]) return input[f]; } return ''; } function normCmd(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); } /** Bash → floor? whole-string (chaining/quotes) ИЛИ любой floor-сегмент (посегментно). */ export function bashIsFloor(command) { const raw = String(command || ''); if (classifyDestructive(raw).floor) return true; const tok = tokenizeBash(raw); if (tok && tok.ok && Array.isArray(tok.segments)) { for (const s of tok.segments) { if (classifyDestructive((s.tokens || []).join(' ')).floor) return true; } } return false; } /** Дверь владельца: свежее (≤window) одобрение ТОЧНОЙ команды. read-only. */ export function approvalOpen(command, approvedGitOps, now) { if (!Array.isArray(approvedGitOps) || approvedGitOps.length === 0) return false; const target = normCmd(command); if (!target) return false; return approvedGitOps.some( // F-6: свежесть = ts в прошлом И в пределах окна. Нижняя граница (now - ts >= 0) отсекает // одобрение с будущим ts (часовой сдвиг/подлог), которое иначе открывало бы дверь. (op) => op && normCmd(op.command) === target && typeof op.ts === 'number' && now - op.ts >= 0 && now - op.ts <= APPROVE_WINDOW_MS, ); } /** * Решение пола. block=true → необратимое без двери / запись в секрет-runtime / fail-close. * @param {object} p * @param {{name:string,input:object}} p.toolUse * @param {Array<{command:string,ts:number}>} [p.approvedGitOps] - read-only approve_git_operation * @param {number} [p.now] * @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism) * @returns {{block:boolean, reason:string}} */ export function floorDecide({ toolUse, approvedGitOps = [], now = Date.now(), normalizeImpl = pathNormalize }) { if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' }; const name = toolUse.name; const input = toolUse.input || {}; if (name === 'Bash') { if (bashIsFloor(input.command || '')) { if (approvalOpen(input.command || '', approvedGitOps, now)) { return { block: false, reason: 'floor: необратимое разрешено дверью владельца (свежее approve_git_operation)' }; } return { block: true, reason: 'floor: необратимая команда без двери владельца — заблокировано (вето-до-плана)' }; } return { block: false, reason: 'floor: Bash не необратимо' }; } if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' }; // P10-a (атака-линза): путь записи проверяется tool-agnostic (как enforce-runtime-write-deny), // не только для именованных Write/Edit — ловит MCP-writer'ы (.env/runtime под чужим tool-name). const fp = extractWritePath(input); if (!fp) return { block: false, reason: 'floor: нет пути записи' }; let norm; try { norm = String(normalizeImpl(fp) || ''); } catch { return { block: true, reason: 'floor: путь записи не резолвится — fail-CLOSED' }; } const slashed = norm.split('\\').join('/'); if (RUNTIME_RE.test(slashed)) return { block: true, reason: 'floor: запись в ~/.claude/runtime запрещена (side-channel)' }; if (SECRET_PATH_RE.some((re) => re.test(slashed))) return { block: true, reason: 'floor: запись в секрет (.env/ключ/cert) запрещена' }; return { block: false, reason: 'floor: запись в обычный файл' }; }