5bcf229e4f
Снятие печати плана (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>
569 lines
47 KiB
JavaScript
569 lines
47 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, 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();
|