Files
brain/tools/enforce-supreme-gate.mjs
T
Дмитрий 5bcf229e4f feat: plan-done и арбитраж gate3 — только терминальный грант (consent forgery B4)
Снятие печати плана (plan-done) и арбитраж gate3 (gate3-arb:accept/continue) — тело-агностичные
согласия, обходящие/снимающие стену — теперь открываются ТОЛЬКО терминальным грантом владельца
(Поза 1, HOLE-4). supreme-gate: новый параметр terminalGrants в decideMode/runGate, PLAN_FINISH_ACTION
проверяется против него (лёгкий escape остаётся на chat-грантах); main грузит loadTerminalGrants.
gate3-loop: арбитраж-гранты грузятся через loadTerminalGrants (loader-swap; resolveOwnerArbitration
агностична). Ядро стены: 138/138, gate3 44/44, полный свод 4346. Спека §B/§DEC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:03:10 +03:00

569 lines
47 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, sanitizeSessionTools, sessionProduces } 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, loadTerminalGrants } 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']); // меняют только черновик сессии, не мир
// B+C (2026-06-18): «смотрящие/спрашивающие» внешние инструменты — ничего не меняют, как чтение.
// Свободны и в разговорном, и под планом (decideMode). НЕ путать с isObserveOnly (локальный zero-effect):
// здесь намеренно входят WebFetch/WebSearch/ToolSearch и read-only браузер. Действующий браузер
// (click/type/fill/select) и MCP-запись сюда НЕ входят — они идут через tools-json сеанса.
const QUERY_ONLY_TOOLS = new Set(['ToolSearch', 'WebFetch', 'WebSearch']);
const READONLY_BROWSER_SUFFIXES = ['browser_navigate', 'browser_snapshot', 'browser_wait_for', 'browser_take_screenshot'];
export function isQueryOnly(toolUse) {
if (!toolUse || typeof toolUse.name !== 'string') return false;
const n = toolUse.name;
if (QUERY_ONLY_TOOLS.has(n)) return true;
return READONLY_BROWSER_SUFFIXES.some((s) => n.endsWith(s));
}
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 || '';
// verdict-wait — строго read-only сторож видимости (только чтение снимка). Узкий именованный
// пропуск: ровно `node [tools/]verdict-wait.mjs <безопасные-аргументы>`, якорь конца строки →
// никаких ;&|>< (цепочки/субшелл/редирект исключены), чужой node сюда не попадает.
if (/^\s*node\s+(?:\.\/)?(?:tools\/)?verdict-wait\.mjs(?:\s+[\w:.\-/]+)*\s*$/.test(cmd)) return true;
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, tentative = null) {
const payload = tentative ? { plan_id: planId, ptr, tentative } : { plan_id: planId, ptr };
const sig = signPayload(payload, key, RECEIPT_DOMAINS.STEP_PTR);
return tentative ? { plan_id: planId, ptr, tentative, sig } : { plan_id: planId, ptr, sig };
}
export function verifyStepState(stored, key) {
if (!stored || typeof stored !== 'object') return false;
const payload = stored.tentative
? { plan_id: stored.plan_id, ptr: stored.ptr, tentative: stored.tentative }
: { plan_id: stored.plan_id, ptr: stored.ptr };
return verifyReceipt({ ...payload, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR);
}
/**
* F-J: указатель ПРЕДВАРИТЕЛЬНОЙ пометки (зеркало resolveStepPtr). Возвращает toPtr
* (целое / массив-индексов ≥0) при совпадении plan_id и валидной подписи; иначе null
* (нет плана / чужой план / битая подпись / нет пометки) — fail-CLOSED.
*/
export function resolveTentative(stored, currentPlanId, verify = null) {
if (!currentPlanId || !stored || typeof stored !== 'object') return null;
if (stored.plan_id !== currentPlanId) return null;
if (verify && !verify(stored)) return null;
const t = stored.tentative;
if (!t || typeof t !== 'object') return null;
const toPtr = t.toPtr;
if (Number.isInteger(toPtr) && toPtr >= 0) return toPtr;
if (Array.isArray(toPtr) && toPtr.length > 0 && toPtr.every((n) => Number.isInteger(n) && n >= 0)) return toPtr;
return null;
}
/**
* F-J: реконсиляция открытой пометки против входного действия (чистая). Двухтактный сдвиг:
* commit — действие = шаг по toPtr (участник делает следующий шаг ⇒ прошлый исполнился);
* discard — действие = шаг по committedPtr (повтор ⇒ прошлый был заблокирован, не исполнился);
* hold — ни то, ни другое (пометку держим, действие блокируется против текущего шага);
* none — пометки нет.
* Различимость commit/discard опирается на DR-1 (нет двух идентичных шагов подряд).
*/
export function computeReconcile({ frozenPlan, incomingAction, committedPtr, tentativeToPtr, normalize }) {
if (tentativeToPtr == null) return { state: 'none', effPtr: committedPtr };
const steps = (frozenPlan && frozenPlan.steps) || [];
const toStep = treeLeafAt(steps, tentativeToPtr);
if (toStep && actionMatchesStep(toStep, incomingAction, { normalize })) {
return { state: 'commit', effPtr: tentativeToPtr };
}
const fromStep = treeLeafAt(steps, committedPtr);
if (fromStep && actionMatchesStep(fromStep, incomingAction, { normalize })) {
return { state: 'discard', effPtr: committedPtr };
}
return { state: 'hold', effPtr: committedPtr };
}
/**
* 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 (isQueryOnly(toolUse)) return { decision: 'allow', reason: 'смотрящий инструмент (query-only) — свободно под планом (B+C), указатель не двигается' };
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: 'план исчерпан / указатель не резолвится в лист' };
// B+C ч.2 (точка 3, спека §3.2): указатель на шаге-сеансе. Действующие инструменты сеанса
// (sanitized tools) и ПРОМЕЖУТОЧНЫЕ produces-файлы разрешены БЕЗ сдвига указателя — сколько
// угодно, по живым ref (стена клики с шагами НЕ сверяет). query-only уже пропущен выше (свободен
// под планом). Сеанс ЗАКРЫВАЕТ запись ПОСЛЕДНЕГО produces — она падает в actionMatchesStep ниже
// (матч сеанса = последний produces) → обычный сдвиг указателя. Пол применяется и здесь
// (defense-in-depth §3.3): промежуточный produces в runtime/секрет → вето.
if (step.op === 'session') {
const act = actionOf(toolUse);
const { allowed } = sanitizeSessionTools(step.tools);
const isSessionTool = allowed.includes(toolUse?.name);
const produces = sessionProduces(step);
const nz = (p) => { try { return (normalize ? normalize(String(p)) : String(p)); } catch { return null; } };
const target = nz(act.object);
const closer = produces.length ? produces[produces.length - 1] : null;
const isProducesWrite = String(act.op) === 'Write' && target != null && produces.some((p) => nz(p) === target);
const isCloser = isProducesWrite && closer != null && nz(closer) === target;
if (isSessionTool || (isProducesWrite && !isCloser)) {
if (floorDecide({ toolUse, escapeGrants: [], escapeConsumed: [], now: 0 }).block) {
return { decision: 'block', reason: `сеанс осмотра ${step.n}: пол наложил бы вето на это действие — стена не двигает указатель, нужна дверь владельца (escape) (§3.3)` };
}
const why = isSessionTool ? `инструмент «${toolUse.name}» из набора сеанса` : 'промежуточный итоговый файл (сеанс закроет запись последнего produces)';
return { decision: 'allow', reason: `сеанс осмотра ${step.n}: ${why} — указатель не двигается`, advance: false };
}
// Не инструмент сеанса и не промежуточный produces → падаем в actionMatchesStep:
// запись ПОСЛЕДНЕГО produces → матч (сдвиг, сеанс закрыт); иначе block «действие не в плане».
}
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 = [], terminalGrants = [], 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), возврат в разговорный. Не требует валидного артефакта.
// Поза 1 (#B4): снятие печати — ТЯЖЁЛОЕ → ТОЛЬКО терминальный грант владельца (terminalGrants),
// не chat floor_escape (иначе контроллер досрочно снимает стену сочинённой меткой).
if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, terminalGrants, escapeConsumed, now)) {
return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' };
}
if (!frozenPlan) {
if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/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) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/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) || isQueryOnly(toolUse)) {
return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query (не 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, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], terminalGrants = [], now = Date.now() }) {
const toolUse = { name: event.tool_name, input: event.tool_input };
const incomingAction = actionOf(toolUse);
// §3.4 (десинк fix, ленивое завершение Фазы 5): план был доведён до конца на ПРОШЛОМ действии
// (committed-указатель за последним листом), но печать НЕ снималась синхронно на последнем шаге —
// чтобы со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидел валидный план на код-пуше и не дал
// ложный блок. Снимаем печать ЛЕНИВО ЗДЕСЬ → текущее действие идёт в разговорном режиме (frozenPlan
// обнулён). Снятие best-effort (сбой не ломает). Указатель за концом резолвится в null-лист.
if (frozenPlan && Array.isArray(frozenPlan.steps) && frozenPlan.steps.length > 0 && (!verifyImpl || verifyImpl(frozenPlan, key))) {
let exhausted = false;
try { exhausted = treeLeafAt(frozenPlan.steps, stepPtr) == null; } catch { exhausted = false; }
if (exhausted) {
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
frozenPlan = null; // далее — разговорный режим (печать снята)
tentativeToPtr = null;
}
}
// F-J: двухтактный сдвиг. Сверить открытую ПРЕДВАРИТЕЛЬНУЮ пометку с входным действием ДО
// решения: commit (= шаг по toPtr → прошлый исполнился) / discard (= повтор шага → прошлый был
// заблокирован, не исполнился) / hold / none. Решение принимается по эффективному указателю.
const rec = computeReconcile({ frozenPlan, incomingAction, committedPtr: stepPtr, tentativeToPtr, normalize });
const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr: rec.effPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, terminalGrants, now });
// FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал
// (escape:true), указатель И пометку НЕ трогаем (escape — не шаг плана). Сбой журнала escape
// НЕ блокирует (санкционирован владельцем). Помеченная escape-запись снимает будущий
// false-positive реконсилера «action-without-record» для легитимного escape.
if (r.mode === 'escape') {
if (typeof journal === 'function') {
try { journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr, 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 };
}
// F-J: применить реконсиляцию пометки (только in-band). commit/discard фиксируют новый
// committed-указатель и снимают пометку (saveStep с tentative=null).
if (rec.state === 'commit' || rec.state === 'discard') saveStep(rec.effPtr, null);
// W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается
// в message вывода хука (владелец видит «энфорсмент off» громко).
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).
let recorded;
try {
recorded = journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr + 1, at: event.nowMs ?? null }) !== false;
} catch { recorded = false; }
if (!recorded) {
return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' };
}
if (r.planComplete) {
// Последний шаг: ранний сдвиг указателя за конец + метка петли (E-S1). Печать НЕ снимаем
// здесь (§3.4): синхронное снятие на последнем шаге опережало co-хук criterion-gate (он видел
// «нет плана» → ложный блок код-пуша). Печать снимается ЛЕНИВО на СЛЕДУЮЩЕМ действии (ветка
// ленивого завершения в начале runGate, указатель за концом) → план жив для co-хуков сейчас.
saveStep(r.advanceTo, null);
if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* E-S1: сбой метки не ломает завершение */ } }
} else {
// F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Committed-указатель остаётся на
// текущем шаге; toPtr подтвердится следующим действием (commit) либо сбросится (discard).
saveStep(rec.effPtr, { toPtr: r.advanceTo });
}
}
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 = []; let terminalGrants = [];
try {
event = parseEventJson(await readStdin());
if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) { exitDecision({ block: false }); return; }
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-пропуски (лёгкие escape)
escapeConsumed = loadConsumed(sess); // отметки one-shot погашения
terminalGrants = loadTerminalGrants(sess); // Поза 1 (#B4): тяжёлые (plan-done) — только терминал владельца
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 verifyCb = key ? (s) => verifyStepState(s, key) : null;
const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, verifyCb); // R-27 привязка + R-19 подпись
const tentativeToPtr = resolveTentative(stored, frozenPlan?.plan_id, verifyCb); // F-J двухтактный сдвиг
const { writeLoopOpen: writeLoopOpenMarker } = await import('./enforce-gate3-loop.mjs');
const { loadTaskId } = await import('./router-task-id.mjs');
const r = runGate({
event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr, key, escapeGrants, escapeConsumed, terminalGrants,
journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
saveStep: (n, tentative = null) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key, tentative))), // R-19 + F-J
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение
writeLoopOpen: () => { // E-S1: метка «петля открыта» на planComplete (in-band)
let taskId = null;
try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; }
writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], delivery: frozenPlan?.delivery ?? 'internal', at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs });
},
});
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();