397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
443 lines
36 KiB
JavaScript
443 lines
36 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 } 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();
|