Files
brain/tools/enforce-supreme-gate.mjs
T

443 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, treeLeaves } from './plan-lock.mjs';
import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.mjs';
// W2 (C2, нах.F3): дисциплина чтения ДР-1 (модуль D) + нормализация путей для продюсера SE5.
import { decideReadEvent } from './reading-discipline.mjs';
import { pathNormalize } from './path-normalization.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 { floorDecide } from './floor-decide.mjs';
import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
// Фаза 5 Task 5.2 (Вариант А): зарезервированная canonical-метка finish-гранта владельца
// «план завершён досрочно». НЕ совпадает ни с одним реальным действием (canonicalAction даёт
// write:/bash:/skill:/mcp:/powershell:/unknown:) → срабатывает только на намеренный finish-грант.
export const PLAN_FINISH_ACTION = 'plan-done';
import { logGuardBlock } from './guard-block-log.mjs';
import { existsSync } from 'node:fs';
// M7 Ф8 (bootstrap): авторская запись НОВОГО артефакта/плана (Write нового .md в
// docs/superpowers/{specs,plans}/) — это КАНАЛ печати, не реализация. В разговорном режиме
// пропускаем: судья оценит содержимое и опечатает ТОЛЬКО на GO. Перезапись существующего
// файла НЕ разрешаем (existsSync). До печати запись власти не даёт — стена закрыта для правок.
const AUTHORING_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\](?:specs|plans)[/\\][^/\\]+\.md$/i;
export function isAuthoringWrite(toolUse, { existsImpl = existsSync } = {}) {
if (!toolUse || toolUse.name !== 'Write') return false;
const fp = String(toolUse.input?.file_path || '');
if (!AUTHORING_PATH_RE.test(fp)) return false;
try { return !existsImpl(fp); } catch { return false; }
}
// Узкий технический 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;
}
// Навык, ОБЪЯВЛЕННЫЙ в опломбированном плане (frozenPlan.skills — подписанное поле, Вариант 1),
// разрешён к вызову как обеспечение исполнения плана (снимает дедлок «объявил, но не вызвать»).
// Матч по суффиксу (как seed). Указатель шага НЕ двигает — это не шаг плана.
export function isPlanDeclaredSkill(toolUse, frozenPlan) {
if (!toolUse || toolUse.name !== 'Skill' || !frozenPlan) return false;
const invoked = String(toolUse.input?.skill || '').toLowerCase();
if (!invoked) return false;
const declared = Array.isArray(frozenPlan.skills) ? frozenPlan.skills : [];
// Объявлено может быть имя плагина ('claude-md-management') или конкретный навык
// ('superpowers:test-driven-development'). Матч: точное равенство ИЛИ вызванный начинается
// с 'объявленный:' (плагин-префикс) ИЛИ совпадение суффиксов (бэр-имена навыка).
return declared.some((d) => {
const dd = String(d || '').toLowerCase();
if (!dd) return false;
return invoked === dd || invoked.startsWith(dd + ':') || skillSuffix(invoked) === skillSuffix(dd);
});
}
// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
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}`;
}
/**
* W2 (C2, SE5/Д-С2-1): продюсер planAuthorizesPath для гейта ДР-1. Op-АГНОСТИЧНЫЙ
* матч по нормализованному object листьев плана (любой лист с этим путём авторизует
* чтение — вид-4 «harness-обязательное»: Read обязателен перед запланированным Edit;
* строгий op+object матч сломал бы harness-цепочку). Bash-шаги (object=команда) пути
* НЕ авторизуют. fail-safe: битый план/бросок normalize → false (→ сырьё → блок).
*/
export function buildPlanAuthorizesPath(frozenPlan, { stepPtr = 0, normalize = pathNormalize } = {}) {
return (path) => {
try {
const steps = (frozenPlan && frozenPlan.steps) || [];
const leaves = treeLeaves(steps);
// F-C2-2 (sharp-edges C2, решение владельца «без дыр в дисциплине»): scope =
// ТЕКУЩИЙ лист + ПРОЙДЕННЫЕ (префикс до stepPtr включительно). Будущие шаги НЕ
// авторизованы (фронт-ран сырья закрыт); перечитка исполненного не ломается.
// Указатель не резолвится в лист → fail-closed (ничего не авторизовано).
const cur = treeLeafAt(steps, stepPtr);
const idx = cur ? leaves.indexOf(cur) : -1;
if (idx < 0) return false;
const target = normalize(String(path));
return leaves.slice(0, idx + 1).some((s) => {
if (!s || String(s.op) === 'Bash') return false;
const obj = String(s.object ?? '').trim();
if (!obj) return false;
try { return normalize(obj) === target; } catch { return false; }
});
} catch { return false; }
};
}
/** W2: расширение пути для classifyReadingContent (без node:path — зеркало D). */
function extOf(p) {
const m = String(p || '').match(/\.[^./\\]+$/);
return m ? m[0].toLowerCase() : '';
}
/**
* Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие
* совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится
* в опечатанном артефакте той же версии (закрытая дверь C-5). Иначе block.
* W2 (нах.F3): в impl-режиме (frozenPlan) Read/Grep проходят гейт ДР-1 (модуль D)
* ДО observe-only — авторское сырьё вне шага плана блокируется; граф-карта /
* авторизованный-планом путь — свободны. isCriticProbe в стене всегда false
* (mentor-derived сигнал сюда не доставляется — Д-С2-2); разговорный режим не
* затронут (decide зовётся только из impl-ветки decideMode).
* @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 (isPlanDeclaredSkill(toolUse, frozenPlan)) return { decision: 'allow', reason: 'навык объявлен в опломбированном плане (вызов разрешён, указатель не двигается)' };
// M7 Ф8 (re-plan): авторская запись НОВОГО плана/спеки разрешена и В РЕЖИМЕ РЕАЛИЗАЦИИ —
// иначе «по ходу работы возникло препятствие, нужно переписать план» не сработает (стена
// держит старый план, новый не записать). Безопасно: новый план так же судят наставник +
// судья + freeze-gate; запись черновика власти не даёт до печати (как в разговорном режиме).
if (isAuthoringWrite(toolUse)) return { decision: 'allow', reason: 'authoring write (re-plan, build-loop) — impl-режим' };
if (frozenPlan && (toolUse?.name === 'Read' || toolUse?.name === 'Grep')) {
const p = actionOf(toolUse).object;
const ev = decideReadEvent({
ext: extOf(p), path: p, frozenPlan: true,
planAuthorizesPath: buildPlanAuthorizesPath(frozenPlan, { stepPtr, normalize }),
});
if (ev.gate.block) {
const t = ev.content && ev.content.contentType ? ` [тип: ${ev.content.contentType}]` : '';
return { decision: 'block', reason: `${ev.gate.reason}${t} (W2)` };
}
}
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 стена всё равно не двигает указатель на снос.
// Δ7+ (floor-desync fix 2026-06-14): стена НЕ двигает указатель на ЛЮБОЕ действие, которое
// заблокировал бы ПОЛ — не только classify-destructive floor-набор (rm-rf/force-push/migrate),
// но и content-block правило 8 (node -e/curl/eval — matchBashHardBlacklist), PS, запись в
// runtime/секрет. Раньше Δ7 смотрел только .floor → content-block-шаг проскакивал со сдвигом
// указателя, пол рубил исполнение → шаг терялся (desync, потеря safety-шага). Предикат —
// floorDecide с ПУСТЫМ escape (escape владельца обрабатывается в decideMode ДО decide);
// order-independent (не зависит от порядка регистрации floor/supreme в settings.json).
if (floorDecide({ toolUse, escapeGrants: [], escapeConsumed: [], now: 0 }).block) {
return { decision: 'block', reason: `шаг ${step.n}: пол наложил бы вето на это действие — стена не двигает указатель, нужна дверь владельца (escape) (Δ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)' }; }
// Фаза 5 (чистое завершение): этот шаг — последний, если следующий указатель уже не
// резолвится в лист. runGate тогда сам снимет печать → следующее действие в разговорном
// (вместо вечного «план исчерпан»). Сбой проверки → не complete (печать держится, безопасно).
let planComplete = false;
try { planComplete = !treeLeafAt(frozenPlan.steps, advanceTo); } catch { planComplete = false; }
return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo, planComplete };
}
/** ✅O18: рассинхрон печатей judge_mode — РОВНО одна 'live-block' (XOR). Обе одинаковы → false. */
export function judgeModeMismatch(planMode, artifactMode) {
return (planMode === 'live-block') !== (artifactMode === 'live-block');
}
/**
* Выбор режима стены (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) — указатель не двигается' };
}
// Фаза 5 Task 5.2 (Вариант А): досрочное завершение плана владельцем. Открыт finish-грант
// (floor_escape с зарезервированной меткой PLAN_FINISH_ACTION — её НЕ порождает ни одно
// реальное действие) И есть запечатанный план → план завершается: печать снимается
// (runGate зовёт removeFrozenPlan), возврат в разговорный. Не требует валидного артефакта.
if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, escapeGrants, escapeConsumed, now)) {
return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' };
}
if (!frozenPlan) {
if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (разговорный режим)' };
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) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (бэкстоп: артефакт не опечатан)' };
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') {
// ✅O18: рассинхрон (одна печать live-block, другая нет) — fail-safe направление СОХРАНЕНО
// (→ разговорный), но ГРОМКО: warn-поле в возврат (owner-резюме гейта-1 покажет, wiring в C).
const warnFields = judgeModeMismatch(frozenPlan.judge_mode, frozenArtifact.judge_mode)
? { warn: true, warnReason: 'judge_mode рассинхрон план≠артефакт — энфорсмент off (O18)' } : {};
if (isSeed(toolUse) || isObserveOnly(toolUse)) {
return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (не live-block-печать, SE-2)', ...warnFields };
}
return { decision: 'block', mode: 'conversational', reason: 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)', ...warnFields };
}
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, removeFrozenPlan, 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 };
}
// Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать
// (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем.
if (r.finishPlan) {
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
return { block: false, message: r.reason };
}
// W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается
// в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме
// гейта-1 — поведенческая сборка контроллера, owner-activation).
const withWarn = (msg) => (r.warn ? `${msg}${r.warnReason}` : msg);
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);
// Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает
// печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан»,
// не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow.
if (r.planComplete && typeof removeFrozenPlan === 'function') {
try { removeFrozenPlan(); } catch { /* best-effort */ }
}
}
return { block: r.decision === 'block', message: withWarn(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, removeFrozenPlan } = 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: подписано
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение
});
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();