#!/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 — пол строже). * * Аварийный выход владельца (floor_escape, М6): необратимое действие проходит ТОЛЬКО при * наличии свежего (≤5 мин) непогашенного пропуска с ТОЧНЫМ совпадением канон-строки * (escape-grant::canonicalAction). Пропуски читаются READ-ONLY (floor НЕ потребляет — * one-shot погашение делает отдельный PostToolUse-консьюмер после реального исполнения; * гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime, пишет * PostToolUse-хук на реальный AskUser → toFloorEscapeRecord). Сквозной: тот же пропуск * чтут стена М2 и egress-классификатор. * * Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из * per-session файла; unknown-сессия → пустой список пропусков → блок. * fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor. */ import { classifyDestructive } from './classify-destructive.mjs'; import { tokenizeBash, detectSubshell } from './bash-tokenizer.mjs'; import { pathNormalize } from './path-normalization.mjs'; import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs'; import { matchBashHardBlacklist } from './shell-content-rules.mjs'; import { psContentBlock } from './powershell-destructive.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-ключи ]; // M7 Task 1.4 (P-3, forge-страж): PS-запись в защищённый путь. Поле `command` не парсится // общим write-стражем (extractWritePath ждёт file_path/path/…), поэтому PS-глагол записи в // ~/.claude/runtime / .env / секрет подделал бы escape-грант. NB (находка реализации): plan-версия // тестила whole-string с $-якорем — `.env` в позиции аргумента (-Path app/.env -Value …) терялся. // Робастно: проверяем КАЖДЫЙ токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE. // sharp-edges (после 1.4): +алиасы PS-глаголов записи (sc/ac/cpi/copy/mi/move/ni/tee) — иначе // `sc ~/.claude/runtime/x` (алиас Set-Content) форджит escape-грант мимо литерального глагола. const PS_WRITE_VERB_RE = /\b(?:Set-Content|Add-Content|Out-File|Copy-Item|New-Item|Tee-Object|Move-Item|sc|ac|cpi|copy|mi|move|ni|tee)\b/i; function psProtectedWrite(cmd) { const s = String(cmd || ''); const isWrite = PS_WRITE_VERB_RE.test(s) || /(?:^|[^0-9>&])>{1,2}(?![>&])/.test(s); if (!isWrite) return false; const slashed = s.split('\\').join('/'); if (RUNTIME_RE.test(slashed)) return true; // -Path~/.claude/runtime слитно тоже for (const raw of s.split(/\s+/)) { const t = raw.replace(/^['"]|['"]$/g, '').split('\\').join('/'); if (RUNTIME_RE.test(t) || SECRET_PATH_RE.some((re) => re.test(t))) return true; } return false; } 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']; 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 ''; } /** 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; } /** * Bash → content-block? (правило 8 §4.1, V1). Опасное-по-СОДЕРЖАНИЮ (произвольное * исполнение / install / egress / redirect) — единый матчер (P-1), whole-string И * посегментно: паритет с bashIsFloor (P-4). NB: matchBashHardBlacklist подстрочный — * `echo "node -e foo"` тоже даёт true (FP-класс, принятый floor'ом для `git push "--force"`; * fail-safe, escapable). Это осознанно: для пола under-block страшнее over-block. */ export function bashIsContentBlock(command) { const raw = String(command || ''); if (matchBashHardBlacklist(raw)) return true; // SE-hardening (M7 Task 1.3 sharp-edges): sub-shell ($()/backtick/process-subst/heredoc) = // произвольное исполнение, не де-обфусцируемое подстрочным матчером (split-assembly // `$(echo no)$(echo de) -e` собирает интерпретатор только при shell-eval). Рубим как класс, // независимо от parse-успеха — закрывает дыру ДО Фазы 8 (увольнение router-gate). Escapable; // router-gate тоже блокирует все sub-shell → 0 новых FP. if (detectSubshell(raw).found) return true; const tok = tokenizeBash(raw); if (tok && tok.ok && Array.isArray(tok.segments)) { for (const s of tok.segments) { if (matchBashHardBlacklist((s.tokens || []).join(' '))) return true; } } return false; } /** * Решение пола. block=true → необратимое без аварийного выхода / запись в секрет-runtime * без аварийного выхода / fail-close. Аварийный выход (floor_escape) — сквозной: свежий * (≤5 мин) непогашенный пропуск с ТОЧНЫМ совпадением канон-строки действия снимает блок * в любой ветке (Bash-floor и запись в runtime/секрет). Блок-сообщение выводит точный * canonicalAction — владелец дословно вставляет его как `FLOOR-ESCAPE: <строка>` (G-5). * @param {object} p * @param {{name:string,input:object}} p.toolUse * @param {Array<{action:string,ts:number}>} [p.escapeGrants] - read-only floor_escape-пропуски * @param {Array<{action:string,ts:number}>} [p.escapeConsumed] - отметки one-shot погашения * @param {number} [p.now] * @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism) * @returns {{block:boolean, reason:string}} */ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize, blessedOps = null }) { if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' }; const name = toolUse.name; const input = toolUse.input || {}; const action = canonicalAction(name, input, { normalizeImpl }); const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now); if (name === 'Bash') { const cmd = input.command || ''; const nuclear = bashIsFloor(cmd); // classify-destructive floor-набор (rm -rf/force-push/migrate:fresh/db:wipe) // M7 Task 1.3 (правило 8, V1): content-block по СОДЕРЖАНИЮ — независимо от плана, escapable. if (bashIsContentBlock(cmd)) { // D1 (благословлённый ops-runbook §3.3-3.4): content-block (НЕ ядерная) команда дословно из // опечатанного deploy-плана под открытым ops-runbook: → пропуск (ОДИН грант на весь // runbook, не на каждую команду). Ядерный набор (bashIsFloor) ИСКЛЮЧЁН — rm -rf/force-push // остаются на per-command escape; `rm -rf` (одновременно content-block И floor) не благословляется. if (!nuclear && typeof blessedOps === 'function' && blessedOps(cmd)) { return { block: false, reason: 'floor: благословлённый ops-шаг runbook (ops-runbook:) — пропуск под согласием владельца' }; } if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' }; return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` }; } if (nuclear) { if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом владельца (floor_escape)' }; return { block: true, reason: `floor: необратимая команда без аварийного выхода — блок (вето-до-плана); FLOOR-ESCAPE: ${action}` }; } return { block: false, reason: 'floor: Bash не необратимо' }; } // M7 Task 1.4 + PS single-source: PowerShell — psContentBlock (тонкий floor-предикат над ЕДИНЫМ // matchPsHardBlacklist = powershell-gate; variant-analysis закрыл дрейф psContentBlock⊂gate) + // P-3 forge-страж psProtectedWrite (для New-Item-в-протектед, что у́же blacklist'а). Симметрия с // bashIsContentBlock. Реоткрытие v3.8 F1: PowerShell был вне пола. Escapable. if (name === 'PowerShell') { const cmd = input.command || ''; if (psContentBlock(cmd) || psProtectedWrite(cmd)) { if (escaped()) return { block: false, reason: 'floor: PS content-block снят аварийным выходом (floor_escape)' }; return { block: true, reason: `floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: ${action}` }; } return { block: false, reason: 'floor: PowerShell не опасно по содержанию' }; } 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; FLOOR-ESCAPE: ${action}` }; } const slashed = norm.split('\\').join('/'); if (RUNTIME_RE.test(slashed) || SECRET_PATH_RE.some((re) => re.test(slashed))) { if (escaped()) return { block: false, reason: 'floor: запись разрешена аварийным выходом владельца (floor_escape)' }; return { block: true, reason: `floor: запись в runtime/секрет без аварийного выхода — блок; FLOOR-ESCAPE: ${action}` }; } return { block: false, reason: 'floor: запись в обычный файл' }; }