bbc053e0a6
Деплой, помеченный **Kind:** deploy и опечатанный (наставник+судья GO, judge_mode=live-block), агент выполняет по белому списку шагов под ОДНИМ согласием владельца `FLOOR-ESCAPE: ops-runbook:<plan-hash>` — без аварийного выхода на каждую команду. «Ядерный» набор (rm -rf/force-push/migrate:fresh/ db:wipe) остаётся на per-command escape. - plan-lock: freezePlan принимает kind (в подписанную базу + хеш, как delivery); не-'normal' добавляет поле, обычные планы байт-идентичны старым печатям. - plan-skills: parsePlanKind (**Kind:** deploy|normal, default normal). - seal-orchestration: sealablePlan/sealPlan прокидывают kind в печать. - escape-grant: loadOpsRunbookGrants (окно = существование плана, БЕЗ 5-мин фильтра) + opsRunbookGrantOpen (точный матч на plan_id). - floor-decide: floorDecide получает инъектируемый blessedOps(cmd); content-block команда из набора пропускается, ЯДЕРНЫЙ набор (bashIsFloor) исключён из послабления. - blessed-ops (новый модуль-мост): buildBlessedOps + loadBlessedOpsForSession — знает план+пол, чтобы СОХРАНИТЬ Δ9 (enforce-floor не зависит от модуля печати плана). Предикат пускает команду только дословно из Bash-листов опечатанного deploy-плана. - enforce-floor: gated — blessed-ops грузит план/гранты ТОЛЬКО при открытом ops-runbook-гранте; без согласия владельца пол плана не касается (Δ9 цел). План: docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md Спека: docs/superpowers/specs/2026-06-18-blessed-ops-runbook-design.md §3.1-3.7. +33 теста, свод 4299 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
63 lines
4.4 KiB
JavaScript
63 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* enforce-floor (Машина 5, Блок 1) — обёртка несущего пола. Matcher '*'
|
||
* (регистрация в settings.json — шаг ВЛАДЕЛЬЦА, ОТДЕЛЬНО от верховной стены М2:
|
||
* снятие стены не снимает пол; чтобы пробить необратимое — нужно снять оба).
|
||
*
|
||
* Зовёт floor-decide ПЕРВЫМ (до seed/observe/членства в плане). Аварийный выход владельца —
|
||
* read-only floor_escape-пропуски через escape-grant::loadFloorEscapes + отметки погашения
|
||
* 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 { loadFloorEscapes, 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 = loadFloorEscapes(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();
|