#!/usr/bin/env node /** * enforce-floor (Машина 5, Блок 1) — обёртка несущего пола. Matcher '*' * (регистрация в settings.json — шаг ВЛАДЕЛЬЦА, ОТДЕЛЬНО от верховной стены М2: * снятие стены не снимает пол; чтобы пробить необратимое — нужно снять оба). * * Зовёт floor-decide ПЕРВЫМ (до seed/observe/членства в плане). Аварийный выход владельца — * read-only floor_escape-пропуски через escape-grant::loadTerminalGrants + отметки погашения * loadConsumed (floor НЕ потребляет — one-shot делает PostToolUse-консьюмер; гонки нет). * * НЕ импортирует plan-lock (Δ9: пол первее плана). fail-CLOSED: любая ошибка → block. */ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; import { floorDecide } from './floor-decide.mjs'; import { loadTerminalGrants, loadConsumed, escapeAllowsEvent } from './escape-grant.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; // D1 (благословлённый ops-runbook): мост план↔пол вынесен в отдельный модуль blessed-ops, чтобы // СОХРАНИТЬ Δ9 (обёртка пола не зависит от модуля печати плана напрямую — см. шапку выше). // loadBlessedOpsForSession зовётся в main ТОЛЬКО когда владелец открыл ops-runbook-грант — // без согласия владельца пол плана НЕ касается. import { loadBlessedOpsForSession } from './blessed-ops.mjs'; /** Чистое решение: делегирует floor-decide. escapeGrants/escapeConsumed/now/normalizeImpl инъектируемы. * M7 Фаза 2 (правило 7б): floorDecide обёрнут в try — если он бросит ДО своего escape-чека, * panic-ветка всё равно оценивает escape владельца (иначе баг = кирпич мимо escape). * floorDecideImpl инъектируем для теста panic-пути. */ export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl, floorDecideImpl = floorDecide, blessedOps = null }) { const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} }; const args = { toolUse, escapeGrants, escapeConsumed, now }; if (normalizeImpl) args.normalizeImpl = normalizeImpl; if (blessedOps) args.blessedOps = blessedOps; // D1: благословлённый ops-runbook предикат try { return floorDecideImpl(args); } catch { if (escapeAllowsEvent(event, escapeGrants, escapeConsumed, now)) { return { block: false, reason: 'floor: panic-escape (floorDecide бросил, escape владельца чтится)' }; } return { block: true, reason: 'floor: внутренняя ошибка вычисления — fail-CLOSED' }; } } async function main() { try { const event = parseEventJson(await readStdin()); const sess = (event && event.session_id) || 'unknown'; const escapeGrants = loadTerminalGrants(sess); // read-only, window-filtered const escapeConsumed = loadConsumed(sess); // отметки one-shot погашения // D1: благословлённый ops-runbook — GATED. blessed-ops грузит план/ops-гранты ТОЛЬКО если // есть открытый ops-runbook-грант (Δ9 сохранён: без согласия владельца пол плана НЕ касается). let blessedOps = null; try { blessedOps = loadBlessedOpsForSession(sess); } catch { blessedOps = null; } // сбой → нет послабления (fail-CLOSED) const r = decide({ event, escapeGrants, escapeConsumed, blessedOps }); if (r.block) logGuardBlock(event, 'М5 Пол', r.reason); exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined }); } catch { exitDecision({ block: true, message: '[floor] внутренняя ошибка — fail-CLOSED' }); } } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();