84231a1470
Доска «кто на посту» (STATUS.md §7) теперь показывает реальные недавние escape владельца и блоки машин М1–М6 вместо хардкода []/[]. - new tools/guard-block-log.mjs: logGuardBlock (best-effort, fail-quiet, Node fs append в guard-blocks-<sess>.jsonl) + loadRecentBlocks/ loadRecentEscapes (скан session-файлов runtime, окно 24ч + cap 10, ts→ISO). - проводка logGuardBlock в block-ветку main() 9 машинных хуков (floor / supreme-gate / judge-gate / snapshot / read-path-deny / mcp-classification / normative-content-rules / verify-gate / criterion-gate). Логгер вызывается ПОСЛЕ решения, не влияет на block; decide() pure не тронут. - status-md-generator CLI: recentEscapes/recentBlocks из читателей вместо []/[]. До флипа Фазы 8 доска показывает 0/0 (хуки не зарегистрированы — данных нет); реальная польза — пост-флип наблюдаемость. TDD: guard-block-log.test (6) + 9 структурных wiring-тестов + 1 board-тест. Гейт закрытия: sharp-edges (промежуточный по 9 хукам + читатели) + variant-analysis (все block-ветки покрыты, иных источников нет). Регрессия tools-only 3465 passed / 2 skipped / 0 failed (было 3449+2skip). 0 регрессий. Plan: docs/superpowers/plans/2026-06-10-guard-board-live-source.md
301 lines
24 KiB
JavaScript
301 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* enforce-supreme-gate — Верховный хук (Машина 2): default-deny на ВСЕ действия,
|
||
* пропуск только при шаге замороженного плана. Матчер = '*' (регистрация в
|
||
* settings.json — owner-шаг). Семена D12/D13 пропускаются всегда (иначе стену
|
||
* нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца).
|
||
*/
|
||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||
import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves, treeLeafAt, validatePlanTree } from './plan-lock.mjs';
|
||
import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.mjs';
|
||
import { classifyBashCommand, READING_CMDS } from './enforce-router-gate.mjs';
|
||
import { tokenizeBash } from './bash-tokenizer.mjs';
|
||
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
|
||
import { assertSafeSessionId } from './action-journal.mjs';
|
||
import { classifyDestructive } from './classify-destructive.mjs';
|
||
import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||
import { logGuardBlock } from './guard-block-log.mjs';
|
||
|
||
// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
|
||
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
|
||
// открывают одобрение. Обоснование — D12/D13.
|
||
// M7 Фаза 3 (SE-K): +реактивные дисциплинарные навыки — их зовут на внезапный баг/ревью
|
||
// без предварительного плана; стена не должна рубить их «вне плана» (иначе дисциплину
|
||
// нельзя вызвать). Это Skill-вызовы (не мутируют мир) → seed-allow безопасен.
|
||
export const SEED_SKILLS = ['writing-plans', 'brainstorming', 'discovery-interview',
|
||
'systematic-debugging', 'test-driven-development', 'requesting-code-review', 'verification-before-completion'];
|
||
export const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']);
|
||
|
||
function skillSuffix(name) { const s = String(name || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; }
|
||
|
||
export function isSeed(toolUse) {
|
||
if (!toolUse) return false;
|
||
if (SEED_TOOLS.has(toolUse.name)) return true;
|
||
if (toolUse.name === 'Skill') return SEED_SKILLS.includes(skillSuffix(toolUse.input?.skill));
|
||
return false;
|
||
}
|
||
|
||
// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
|
||
const OBSERVE_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']); // локальные «смотрят»
|
||
const EPHEMERAL_META_TOOLS = new Set(['TodoWrite']); // меняют только черновик сессии, не мир
|
||
export function isObserveOnly(toolUse, { classifyBash = classifyBashCommand } = {}) {
|
||
if (!toolUse) return false;
|
||
if (OBSERVE_ONLY_TOOLS.has(toolUse.name)) return true;
|
||
if (EPHEMERAL_META_TOOLS.has(toolUse.name)) return true;
|
||
// Условие А: read-only Bash по ЭФФЕКТУ; classifyBash консервативен (редирект/писатель → НЕ readonly).
|
||
if (toolUse.name === 'Bash') {
|
||
const cmd = toolUse.input?.command || '';
|
||
const c = classifyBash(cmd);
|
||
if (!c || c.result !== 'allow' || !/readonly|reading/.test(c.reason || '')) return false;
|
||
// F-A (аудит 2026-06-07): reason «whitelisted reading command(s)» схлопывается,
|
||
// если в цепочке есть ХОТЬ ОДИН читатель (anyReading) — тогда whitelisted-мутатор
|
||
// (composer pint/rector → переписать исходники, php artisan migrate:fresh → дроп БД,
|
||
// pest/npm test → запись, node <script> → произвольный JS), стоящий за `cat x &&`,
|
||
// проскакивал зелёным проходом. Дополнительно требуем, чтобы КАЖДЫЙ сегмент был
|
||
// настоящим читателем (READING_CMDS — единый источник из router-gate), либо команда
|
||
// была одиночным readonly-git (reason это уже подтвердил). Не доверяем схлопнутому reason.
|
||
const tok = tokenizeBash(cmd);
|
||
if (!tok.ok || tok.hasSubshell || !Array.isArray(tok.segments) || tok.segments.length === 0) return false;
|
||
if (tok.segments.length === 1 && tok.segments[0].tokens[0] === 'git') return true;
|
||
return tok.segments.every((s) => READING_CMDS.has(s.tokens[0]));
|
||
}
|
||
// Исходящие (WebFetch/WebSearch/внешний MCP) и мутирующие — НЕ зелёный проход (пол/судья/стена).
|
||
return false;
|
||
}
|
||
|
||
/** Привести событие инструмента к {op, object} для матча по шагу. */
|
||
export function actionOf(toolUse) {
|
||
const op = toolUse?.name;
|
||
const i = toolUse?.input || {};
|
||
let object = '';
|
||
if (op === 'Bash') object = i.command || '';
|
||
else if (op === 'Skill') object = i.skill || '';
|
||
else if (op === 'Task') object = i.subagent_type || '';
|
||
// F1 (2026-06-05): поля выровнены с extractPath (B4) — MCP-писатели несут путь под разными именами.
|
||
else object = i.file_path || i.notebook_path || i.path || i.target_file || i.filename || i.destination || i.dest || i.output_path || i.uri || '';
|
||
return { op, object };
|
||
}
|
||
|
||
/**
|
||
* R-27: указатель шага привязан к plan_id. Чужой план / легаси (голое число) /
|
||
* нет plan_id / нет текущего плана → 0 — перепечать плана НЕ отматывает старый
|
||
* указатель на новый план (иначе шаги нового плана мис-индексируются).
|
||
*/
|
||
export function resolveStepPtr(stored, currentPlanId, verify = null) {
|
||
if (!currentPlanId || !stored || typeof stored !== 'object') return 0;
|
||
if (stored.plan_id !== currentPlanId) return 0;
|
||
if (verify && !verify(stored)) return 0; // R-19: битая/отсутствующая подпись → сброс
|
||
// R-08 (SE-3 secure default): целое (легаси/depth-1) ИЛИ массив-индексов (дерево); иначе → корень.
|
||
const ptr = stored.ptr;
|
||
if (Number.isInteger(ptr) && ptr >= 0) return ptr;
|
||
if (Array.isArray(ptr) && ptr.length > 0 && ptr.every((n) => Number.isInteger(n) && n >= 0)) return ptr;
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* R-19: подпись состояния указателя шага (домен step-ptr). Подмена ptr/plan_id без
|
||
* ключа ломает подпись → resolveStepPtr с verify-колбэком сбросит в 0. Поверх «пола»
|
||
* (runtime-write-deny) — защита-в-глубину; настоящий анти-откат — K6/Машина 4.
|
||
*/
|
||
export function signStepState(planId, ptr, key) {
|
||
return { plan_id: planId, ptr, sig: signPayload({ plan_id: planId, ptr }, key, RECEIPT_DOMAINS.STEP_PTR) };
|
||
}
|
||
export function verifyStepState(stored, key) {
|
||
if (!stored || typeof stored !== 'object') return false;
|
||
return verifyReceipt({ plan_id: stored.plan_id, ptr: stored.ptr, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR);
|
||
}
|
||
|
||
/**
|
||
* R-28: номер сессии берётся из stdin-события (канон Claude Code: event.session_id),
|
||
* НЕ из process.env (CLAUDE_SESSION_ID хуку не выставляется → все сессии слипались
|
||
* бы в файлах «-unknown»). Фолбэк: env → 'unknown'.
|
||
*/
|
||
export function resolveSessionId(event, env = process.env) {
|
||
return (event && event.session_id) || env.CLAUDE_SESSION_ID || 'unknown';
|
||
}
|
||
|
||
/**
|
||
* N3-shared (аудит M1-M4): путь файла указателя шага строится из sessionId. Вынесен
|
||
* в экспортируемый guarded-строитель (как planPath/artifactPath), чтобы guard формы
|
||
* не зависел от порядка вызовов в main() и был тестируем. Бракованный sessionId → throw
|
||
* (в main ловится внешним try/catch → fail-CLOSED).
|
||
*/
|
||
export function stepStatePath(runtimeDir, sessionId) {
|
||
assertSafeSessionId(sessionId);
|
||
const sep = runtimeDir.endsWith('/') ? '' : '/';
|
||
return `${runtimeDir}${sep}plan-step-${sessionId}`;
|
||
}
|
||
|
||
/**
|
||
* Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие
|
||
* совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится
|
||
* в опечатанном артефакте той же версии (закрытая дверь C-5). Иначе block.
|
||
* @returns {{decision:'allow'|'block', reason:string, advanceTo?:number}}
|
||
*/
|
||
export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
|
||
if (isSeed(toolUse)) return { decision: 'allow', reason: 'seed (bootstrap D12/D13)' };
|
||
if (isObserveOnly(toolUse)) return { decision: 'allow', reason: 'observe-only (зелёный проход по способности)' };
|
||
if (!frozenPlan) return { decision: 'block', reason: 'нет замороженного плана — действие вне плана запрещено (default-deny)' };
|
||
if (!verifyImpl(frozenPlan, key)) return { decision: 'block', reason: 'печать плана невалидна (seal/signature) — требуется заново одобрить план' };
|
||
// R-08: структурная валидация дерева плана ДО доверия (fail-CLOSED, SE-2/SE-4)
|
||
if (!validatePlanTree(frozenPlan.steps)) return { decision: 'block', reason: 'структура плана-дерева невалидна (fail-CLOSED)' };
|
||
// R-08: текущий лист по указателю (целое=depth-1 / массив=дерево); спуск через контейнеры, лист только из sealed-дерева (I2)
|
||
const step = treeLeafAt(frozenPlan.steps, stepPtr);
|
||
if (!step) return { decision: 'block', reason: 'план исчерпан / указатель не резолвится в лист' };
|
||
if (!actionMatchesStep(step, actionOf(toolUse), { normalize }))
|
||
return { decision: 'block', reason: `действие не в плане (ожидался шаг ${step.n}: ${step.op} ${step.object})` };
|
||
// Привязка к версии артефакта — только если план опирается на artifact_id:
|
||
if (frozenPlan.artifact_id) {
|
||
if (!frozenArtifact || frozenArtifact.artifact_id !== frozenPlan.artifact_id)
|
||
return { decision: 'block', reason: `артефакт не той версии, под которую печатали план (ждём ${frozenPlan.artifact_id}) — пере-печать` };
|
||
// F2 (2026-06-05): decide самодостаточно проверяет ПЕЧАТЬ артефакта (не только id),
|
||
// чтобы вызов decide() в обход обёртки decideMode нельзя было обмануть подложным артефактом.
|
||
if (!verifyArtifactImpl(frozenArtifact, key))
|
||
return { decision: 'block', reason: 'печать артефакта невалидна (seal) — пере-печать артефакта' };
|
||
}
|
||
// Закрытая дверь (C-5) — F-C (аудит 2026-06-07): шаг с ref ОБЯЗАН резолвиться в
|
||
// опечатанном артефакте НЕЗАВИСИМО от artifact_id плана. Иначе план, замороженный
|
||
// без artifact_id, выключал бы дверь целиком (ref не проверялся). ref без опечатанного
|
||
// артефакта = некогерентная ссылка → блок.
|
||
if (step.ref) {
|
||
if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key))
|
||
return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} требует опечатанного артефакта — вернись в разговор (закрытая дверь C-5)` };
|
||
if (!refResolves(step, frozenArtifact))
|
||
return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5)` };
|
||
}
|
||
// Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже валидный шаг плана, который
|
||
// САМ ПО СЕБЕ необратим (force-push/migrate:fresh/reset --hard/rm -rf — classify-destructive
|
||
// floor-набор), стена НЕ благословляет: указатель не двигается, нужна дверь владельца
|
||
// (floor-хук). При незарегистрированном floor стена всё равно не двигает указатель на снос.
|
||
if (classifyDestructive(actionOf(toolUse).object).floor) {
|
||
return { decision: 'block', reason: `шаг ${step.n}: разрушительное in-plan действие — пол требует двери владельца (стена не двигает указатель, Δ7)` };
|
||
}
|
||
// R-08: следующая позиция = depth-first ход по дереву, сериализованный (целое для depth-1 / массив).
|
||
// SE-1: явный флаг advance (runGate гейтит по нему, не по typeof advanceTo).
|
||
let advanceTo;
|
||
try {
|
||
const cur = normalizeToLeaf(frozenPlan.steps, deserializePointer(stepPtr, frozenPlan.steps));
|
||
advanceTo = serializePointer(advanceOverTree(frozenPlan.steps, cur));
|
||
} catch { return { decision: 'block', reason: 'навигация по дереву превысила предел (fail-CLOSED)' }; }
|
||
return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo };
|
||
}
|
||
|
||
/**
|
||
* Выбор режима стены (C-7). Нет замороженного плана-стройки → разговорный режим:
|
||
* пропускаем только семена/зелёный проход, остальное block. Есть план →
|
||
* реализационный режим, НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10).
|
||
*/
|
||
export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key,
|
||
escapeGrants = [], escapeConsumed = [], now = Date.now(),
|
||
verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
|
||
// G-1 (α): сквозной аварийный выход владельца — раньше всех плановых проверок.
|
||
// allow БЕЗ advanceTo (указатель не двигается; escape — out-of-band, не шаг плана).
|
||
if (escapeGrantOpen(canonicalAction(toolUse?.name, toolUse?.input || {}), escapeGrants, escapeConsumed, now)) {
|
||
return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' };
|
||
}
|
||
if (!frozenPlan) {
|
||
if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (разговорный режим)' };
|
||
return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' };
|
||
}
|
||
if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key)) {
|
||
// F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем
|
||
// и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» +
|
||
// согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы.
|
||
if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (бэкстоп: артефакт не опечатан)' };
|
||
return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' };
|
||
}
|
||
// SE-2 (fail-closed whitelist): энфорсмент ТОЛЬКО при live-block на ОБЕИХ печатях.
|
||
// shadow/null/опечатка/любое ≠ 'live-block' → разговорный (мнимая печать не одобряет энфорсмент).
|
||
// Разбор #1 (VA-a/SE-a): не «явный shadow→блок» (fail-OPEN), а whitelist — единственная
|
||
// fail-OPEN сверка в коде убрана; опечатка режима безопасно уходит в разговорный.
|
||
if (frozenPlan.judge_mode !== 'live-block' || frozenArtifact.judge_mode !== 'live-block') {
|
||
if (isSeed(toolUse) || isObserveOnly(toolUse)) {
|
||
return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (не live-block-печать, SE-2)' };
|
||
}
|
||
return { decision: 'block', mode: 'conversational', reason: 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)' };
|
||
}
|
||
return { ...decide({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize }), mode: 'implementation' };
|
||
}
|
||
|
||
/**
|
||
* Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг.
|
||
* journal/saveStep инъектируются (в main — реальные Node fs).
|
||
*/
|
||
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||
const toolUse = { name: event.tool_name, input: event.tool_input };
|
||
const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now });
|
||
// FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал
|
||
// (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие
|
||
// журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова
|
||
// закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера
|
||
// «action-without-record (обход стены)» для легитимного escape.
|
||
if (r.mode === 'escape') {
|
||
if (typeof journal === 'function') {
|
||
try { journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ }
|
||
}
|
||
return { block: false, message: r.reason };
|
||
}
|
||
if (r.decision === 'allow' && r.advance === true) {
|
||
// Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения —
|
||
// честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ
|
||
// разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает
|
||
// PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок).
|
||
let recorded;
|
||
try {
|
||
recorded = journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null }) !== false;
|
||
} catch { recorded = false; }
|
||
if (!recorded) {
|
||
return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' };
|
||
}
|
||
saveStep(r.advanceTo);
|
||
}
|
||
return { block: r.decision === 'block', message: r.reason };
|
||
}
|
||
|
||
/**
|
||
* Panic-ветка (M7 Фаза 2, правило 7б): сетап main (ключ/план/артефакт/путь-указателя)
|
||
* бросил ДО decideMode → escape владельца всё равно оценён. block:false если открыт
|
||
* escape-грант на действие события; иначе fail-CLOSED block:true.
|
||
*/
|
||
export function panicEscapeDecision(event, escapeGrants = [], escapeConsumed = [], now = Date.now()) {
|
||
return { block: !escapeAllowsEvent(event, escapeGrants, escapeConsumed, now) };
|
||
}
|
||
|
||
async function main() {
|
||
let event = {}; let escapeGrants = []; let escapeConsumed = [];
|
||
try {
|
||
event = parseEventJson(await readStdin());
|
||
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
|
||
const { loadFrozenPlan, loadFrozenArtifact } = await import('./plan-lock.mjs');
|
||
const { journalAppend } = await import('./action-journal.mjs');
|
||
const os = await import('node:os'); const fs = await import('node:fs');
|
||
const runtimeDir = `${os.homedir()}/.claude/runtime`;
|
||
const sess = resolveSessionId(event); // R-28: из stdin-события, не из env
|
||
// M7 Фаза 2 (правило 7б): escape-пропуски грузим РАНО — чтобы panic-ветка в catch
|
||
// имела их, даже если последующий сетап (ключ/план/артефакт/путь) бросит.
|
||
escapeGrants = loadFloorEscapes(sess); // G-1 α: read-only floor_escape-пропуски
|
||
escapeConsumed = loadConsumed(sess); // отметки one-shot погашения
|
||
const key = resolveReceiptKey();
|
||
const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir });
|
||
const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir });
|
||
const stepPath = stepStatePath(runtimeDir, sess); // N3-shared guard формы sessionId
|
||
let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {}
|
||
const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, key ? (s) => verifyStepState(s, key) : null); // R-27 привязка + R-19 подпись
|
||
const r = runGate({
|
||
event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed,
|
||
journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
|
||
saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано
|
||
});
|
||
if (r.block) logGuardBlock(event, 'М2 Стена', r.message);
|
||
exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
|
||
} catch {
|
||
// Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
|
||
const p = panicEscapeDecision(event, escapeGrants, escapeConsumed);
|
||
exitDecision({ block: p.block, message: p.block ? '[supreme-gate] внутренняя ошибка — fail-CLOSED' : undefined });
|
||
}
|
||
}
|
||
|
||
import { fileURLToPath } from 'node:url';
|
||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||
if (isCli) main();
|