2026-06-04 16:48:49 +03:00
#!/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' ;
2026-06-11 19:09:39 +03:00
import { verifyFrozenPlan , verifyFrozenArtifact , actionMatchesStep , nextStep , refResolves , treeLeafAt , validatePlanTree , treeLeaves } from './plan-lock.mjs' ;
2026-06-09 05:12:38 +03:00
import { advanceOverTree , serializePointer , deserializePointer , normalizeToLeaf } from './step-pointer.mjs' ;
2026-06-11 19:09:39 +03:00
// W2 (C2, нах.F3): дисциплина чтения ДР-1 (модуль D) + нормализация путей для продюсера SE5.
import { decideReadEvent } from './reading-discipline.mjs' ;
import { pathNormalize } from './path-normalization.mjs' ;
2026-06-07 04:33:17 +03:00
import { classifyBashCommand , READING _CMDS } from './enforce-router-gate.mjs' ;
import { tokenizeBash } from './bash-tokenizer.mjs' ;
2026-06-05 06:23:35 +03:00
import { signPayload , verifyReceipt , RECEIPT _DOMAINS } from './receipt-sign.mjs' ;
2026-06-07 05:41:07 +03:00
import { assertSafeSessionId } from './action-journal.mjs' ;
2026-06-14 10:58:14 +03:00
import { floorDecide } from './floor-decide.mjs' ;
2026-06-08 10:28:43 +03:00
import { canonicalAction , escapeGrantOpen , escapeAllowsEvent , loadFloorEscapes , loadConsumed } from './escape-grant.mjs' ;
2026-06-13 11:38:09 +03:00
// Фаза 5 Task 5.2 (Вариант А): зарезервированная canonical-метка finish-гранта владельца
// «план завершён досрочно». НЕ совпадает ни с одним реальным действием (canonicalAction даёт
// write:/bash:/skill:/mcp:/powershell:/unknown:) → срабатывает только на намеренный finish-грант.
export const PLAN _FINISH _ACTION = 'plan-done' ;
2026-06-10 04:28:53 +03:00
import { logGuardBlock } from './guard-block-log.mjs' ;
2026-06-12 15:48:17 +03:00
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 ; }
}
2026-06-04 16:48:49 +03:00
// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
// открывают одобрение. Обоснование — D12/D13.
2026-06-08 10:35:33 +03:00
// 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' ] ;
2026-06-09 04:02:27 +03:00
export const SEED _TOOLS = new Set ( [ 'EnterPlanMode' , 'AskUserQuestion' ] ) ;
2026-06-04 16:48:49 +03:00
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 ;
}
2026-06-14 04:08:53 +03:00
// Навык, ОБЪЯВЛЕННЫЙ в опломбированном плане (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 ) ;
} ) ;
}
2026-06-04 16:48:49 +03:00
// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
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' ) {
2026-06-07 04:33:17 +03:00
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 ;
2026-06-18 08:04:50 +03:00
// Owner-authorized 2026-06-18: строго read-only удалённый доступ к боевому порталу
// (ssh liderra-prod '<read>' / gh GET). Достижимо ТОЛЬКО если classifyBash выше вернул
// allow+reading — а для ssh/gh это даёт исключительно ветка remote-read-allow.mjs
// (валидный разбор удалённой read-команды; мутации/чужой хост/пайпы → block там же).
// Локальные reading-команды имеют tokens[0] ∈ READING_CMDS (не ssh/gh) → строка ниже.
if ( tok . segments . length === 1 && ( tok . segments [ 0 ] . tokens [ 0 ] === 'ssh' || tok . segments [ 0 ] . tokens [ 0 ] === 'gh' ) ) return true ;
2026-06-07 04:33:17 +03:00
return tok . segments . every ( ( s ) => READING _CMDS . has ( s . tokens [ 0 ] ) ) ;
2026-06-04 16:48:49 +03:00
}
// Исходящие (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 || '' ;
2026-06-05 04:04:09 +03:00
// 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 || '' ;
2026-06-04 16:48:49 +03:00
return { op , object } ;
}
2026-06-05 04:21:05 +03:00
/**
* R-27: указатель шага привязан к plan_id. Чужой план / легаси (голое число) /
* нет plan_id / нет текущего плана → 0 — перепечать плана НЕ отматывает старый
* указатель на новый план (иначе шаги нового плана мис-индексируются).
*/
2026-06-05 06:23:35 +03:00
export function resolveStepPtr ( stored , currentPlanId , verify = null ) {
2026-06-05 04:21:05 +03:00
if ( ! currentPlanId || ! stored || typeof stored !== 'object' ) return 0 ;
if ( stored . plan _id !== currentPlanId ) return 0 ;
2026-06-05 06:23:35 +03:00
if ( verify && ! verify ( stored ) ) return 0 ; // R-19: битая/отсутствующая подпись → сброс
2026-06-09 05:10:45 +03:00
// 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 ;
2026-06-05 04:21:05 +03:00
}
2026-06-05 06:23:35 +03:00
/**
* 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 ) ;
}
2026-06-05 06:09:45 +03:00
/**
* 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' ;
}
2026-06-07 05:41:07 +03:00
/**
* 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 } ` ;
}
2026-06-11 19:09:39 +03:00
/**
* 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 ( ) : '' ;
}
2026-06-04 16:48:49 +03:00
/**
* Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие
* совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится
* в опечатанном артефакте той же версии (закрытая дверь C-5). Иначе block.
2026-06-11 19:09:39 +03:00
* W2 (нах.F3): в impl-режиме (frozenPlan) Read/Grep проходят гейт ДР-1 (модуль D)
* ДО observe-only — авторское сырьё вне шага плана блокируется; граф-карта /
* авторизованный-планом путь — свободны. isCriticProbe в стене всегда false
* (mentor-derived сигнал сюда не доставляется — Д-С2-2); разговорный режим не
* затронут (decide зовётся только из impl-ветки decideMode).
2026-06-04 16:48:49 +03:00
* @returns {{decision:'allow'|'block', reason:string, advanceTo?:number}}
*/
2026-06-05 06:21:19 +03:00
export function decide ( { toolUse , frozenPlan , frozenArtifact = null , stepPtr = 0 , key , verifyImpl = verifyFrozenPlan , verifyArtifactImpl = verifyFrozenArtifact , normalize } ) {
2026-06-04 16:48:49 +03:00
if ( isSeed ( toolUse ) ) return { decision : 'allow' , reason : 'seed (bootstrap D12/D13)' } ;
2026-06-14 04:08:53 +03:00
if ( isPlanDeclaredSkill ( toolUse , frozenPlan ) ) return { decision : 'allow' , reason : 'навык объявлен в опломбированном плане (вызов разрешён, указатель не двигается)' } ;
2026-06-12 15:48:17 +03:00
// M7 Ф8 (re-plan): авторская запись НОВОГО плана/спеки разрешена и В РЕЖИМЕ РЕАЛИЗАЦИИ —
// иначе «по ходу работы возникло препятствие, нужно переписать план» не сработает (стена
// держит старый план, новый не записать). Безопасно: новый план так же судят наставник +
// судья + freeze-gate; запись черновика власти не даёт до печати (как в разговорном режиме).
if ( isAuthoringWrite ( toolUse ) ) return { decision : 'allow' , reason : 'authoring write (re-plan, build-loop) — impl-режим' } ;
2026-06-11 19:09:39 +03:00
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) ` } ;
}
}
2026-06-04 16:48:49 +03:00
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) — требуется заново одобрить план' } ;
2026-06-09 05:12:38 +03:00
// 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 : 'план исчерпан / указатель не резолвится в лист' } ;
2026-06-04 16:48:49 +03:00
if ( ! actionMatchesStep ( step , actionOf ( toolUse ) , { normalize } ) )
return { decision : 'block' , reason : ` действие не в плане (ожидался шаг ${ step . n } : ${ step . op } ${ step . object } ) ` } ;
2026-06-07 04:33:17 +03:00
// Привязка к версии артефакта — только если план опирается на artifact_id:
2026-06-04 16:48:49 +03:00
if ( frozenPlan . artifact _id ) {
if ( ! frozenArtifact || frozenArtifact . artifact _id !== frozenPlan . artifact _id )
return { decision : 'block' , reason : ` артефакт не той версии, под которую печатали план (ждём ${ frozenPlan . artifact _id } ) — пере-печать ` } ;
2026-06-05 04:05:22 +03:00
// F2 (2026-06-05): decide самодостаточно проверяет ПЕЧАТЬ артефакта (не только id),
// чтобы вызов decide() в обход обёртки decideMode нельзя было обмануть подложным артефактом.
2026-06-05 06:21:19 +03:00
if ( ! verifyArtifactImpl ( frozenArtifact , key ) )
2026-06-05 04:05:22 +03:00
return { decision : 'block' , reason : 'печать артефакта невалидна (seal) — пере-печать артефакта' } ;
2026-06-07 04:33:17 +03:00
}
// Закрытая дверь (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) ` } ;
2026-06-04 16:48:49 +03:00
if ( ! refResolves ( step , frozenArtifact ) )
return { decision : 'block' , reason : ` шаг ${ step . n } : ссылка ${ step . ref } на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5) ` } ;
}
2026-06-07 11:42:58 +03:00
// Δ7 (Машина 5 Пакет 2.5): defense-in-depth М 2. Даже валидный шаг плана, который
// САМ ПО СЕБЕ необратим (force-push/migrate:fresh/reset --hard/rm -rf — classify-destructive
// floor-набор), стена НЕ благословляет: указатель не двигается, нужна дверь владельца
// (floor-хук). При незарегистрированном floor стена всё равно не двигает указатель на снос.
2026-06-14 10:58:14 +03:00
// Δ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+) ` } ;
2026-06-07 11:42:58 +03:00
}
2026-06-09 05:12:38 +03:00
// 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)' } ; }
2026-06-13 11:38:09 +03:00
// Фаза 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 } ;
2026-06-04 16:48:49 +03:00
}
2026-06-11 17:13:48 +03:00
/** ✅O18: рассинхрон печатей judge_mode — РОВНО одна 'live-block' (XOR). Обе одинаковы → false. */
export function judgeModeMismatch ( planMode , artifactMode ) {
return ( planMode === 'live-block' ) !== ( artifactMode === 'live-block' ) ;
}
2026-06-04 16:48:49 +03:00
/**
* Выбор режима стены (C-7). Нет замороженного плана-стройки → разговорный режим:
* пропускаем только семена/зелёный проход, остальное block. Есть план →
* реализационный режим, НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10).
*/
2026-06-07 18:55:56 +03:00
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) — указатель не двигается' } ;
}
2026-06-13 11:38:09 +03:00
// Фаза 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) — печать снята, возврат в разговор' } ;
}
2026-06-04 16:48:49 +03:00
if ( ! frozenPlan ) {
2026-06-12 15:48:17 +03:00
if ( isSeed ( toolUse ) || isObserveOnly ( toolUse ) || isAuthoringWrite ( toolUse ) ) return { decision : 'allow' , mode : 'conversational' , reason : 'seed/observe/authoring (разговорный режим)' } ;
2026-06-04 16:48:49 +03:00
return { decision : 'block' , mode : 'conversational' , reason : 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' } ;
}
2026-06-05 06:21:19 +03:00
if ( ! frozenArtifact || ! verifyArtifactImpl ( frozenArtifact , key ) ) {
2026-06-07 04:33:17 +03:00
// F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем
// и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» +
// согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы.
2026-06-12 15:48:17 +03:00
if ( isSeed ( toolUse ) || isObserveOnly ( toolUse ) || isAuthoringWrite ( toolUse ) ) return { decision : 'allow' , mode : 'conversational' , reason : 'seed/observe/authoring (бэкстоп: артефакт не опечатан)' } ;
2026-06-04 16:48:49 +03:00
return { decision : 'block' , mode : 'conversational' , reason : 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' } ;
}
2026-06-09 19:05:17 +03:00
// 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' ) {
2026-06-11 17:13:48 +03:00
// ✅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)' } : { } ;
2026-06-09 19:05:17 +03:00
if ( isSeed ( toolUse ) || isObserveOnly ( toolUse ) ) {
2026-06-11 17:13:48 +03:00
return { decision : 'allow' , mode : 'conversational' , reason : 'seed/observe (не live-block-печать, SE-2)' , ... warnFields } ;
2026-06-09 19:05:17 +03:00
}
2026-06-11 17:13:48 +03:00
return { decision : 'block' , mode : 'conversational' , reason : 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)' , ... warnFields } ;
2026-06-09 19:05:17 +03:00
}
2026-06-05 06:21:19 +03:00
return { ... decide ( { toolUse , frozenPlan , frozenArtifact , stepPtr , key , verifyImpl , verifyArtifactImpl , normalize } ) , mode : 'implementation' } ;
2026-06-04 16:48:49 +03:00
}
/**
2026-06-14 06:11:11 +03:00
* Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг.
* journal/saveStep инъектируются (в main — реальные Node fs).
2026-06-04 16:48:49 +03:00
*/
2026-06-14 06:11:11 +03:00
export function runGate ( { event , frozenPlan , frozenArtifact , stepPtr , key , verifyImpl , verifyArtifactImpl , normalize , journal , saveStep , removeFrozenPlan , escapeGrants = [ ] , escapeConsumed = [ ] , now = Date . now ( ) } ) {
2026-06-04 16:48:49 +03:00
const toolUse = { name : event . tool _name , input : event . tool _input } ;
2026-06-07 18:55:56 +03:00
const r = decideMode ( { toolUse , frozenPlan , frozenArtifact , stepPtr , key , verifyImpl , verifyArtifactImpl , normalize , escapeGrants , escapeConsumed , now } ) ;
2026-06-14 06:11:11 +03:00
// FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал
// (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие
// журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова
// закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера
// «action-without-record (обход стены)» для легитимного escape.
2026-06-07 19:43:05 +03:00
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 } ;
}
2026-06-14 06:11:11 +03:00
// Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать
// (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем.
2026-06-13 11:38:09 +03:00
if ( r . finishPlan ) {
if ( typeof removeFrozenPlan === 'function' ) { try { removeFrozenPlan ( ) ; } catch { /* best-effort */ } }
return { block : false , message : r . reason } ;
}
2026-06-14 06:11:11 +03:00
// W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается
// в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме
// гейта-1 — поведенческая сборка контроллера, owner-activation).
2026-06-11 19:09:39 +03:00
const withWarn = ( msg ) => ( r . warn ? ` ${ msg } ⚠ ${ r . warnReason } ` : msg ) ;
2026-06-09 05:12:38 +03:00
if ( r . decision === 'allow' && r . advance === true ) {
2026-06-14 06:11:11 +03:00
// Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения —
// честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ
// разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает
// PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок).
2026-06-07 15:29:48 +03:00
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: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' } ;
}
2026-06-04 16:48:49 +03:00
saveStep ( r . advanceTo ) ;
2026-06-14 06:11:11 +03:00
// Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает
// печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан»,
// не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow.
2026-06-13 11:38:09 +03:00
if ( r . planComplete && typeof removeFrozenPlan === 'function' ) {
try { removeFrozenPlan ( ) ; } catch { /* best-effort */ }
}
2026-06-04 16:48:49 +03:00
}
2026-06-14 06:11:11 +03:00
return { block : r . decision === 'block' , message : withWarn ( r . reason ) } ;
2026-06-04 16:48:49 +03:00
}
2026-06-08 10:28:43 +03:00
/**
* 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 ) } ;
}
2026-06-04 16:48:49 +03:00
async function main ( ) {
2026-06-08 10:28:43 +03:00
let event = { } ; let escapeGrants = [ ] ; let escapeConsumed = [ ] ;
2026-06-04 16:48:49 +03:00
try {
2026-06-08 10:28:43 +03:00
event = parseEventJson ( await readStdin ( ) ) ;
2026-06-04 16:48:49 +03:00
const { resolveReceiptKey } = await import ( './receipt-key-config.mjs' ) ;
2026-06-13 11:38:09 +03:00
const { loadFrozenPlan , loadFrozenArtifact , removeFrozenPlan } = await import ( './plan-lock.mjs' ) ;
2026-06-04 16:48:49 +03:00
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 ` ;
2026-06-05 06:09:45 +03:00
const sess = resolveSessionId ( event ) ; // R-28: из stdin-события, не из env
2026-06-08 10:28:43 +03:00
// M7 Фаза 2 (правило 7б): escape-пропуски грузим РАНО — чтобы panic-ветка в catch
// имела их, даже если последующий сетап (ключ/план/артефакт/путь) бросит.
escapeGrants = loadFloorEscapes ( sess ) ; // G-1 α : read-only floor_escape-пропуски
escapeConsumed = loadConsumed ( sess ) ; // отметки one-shot погашения
2026-06-04 16:48:49 +03:00
const key = resolveReceiptKey ( ) ;
const frozenPlan = loadFrozenPlan ( { sessionId : sess , runtimeDir } ) ;
const frozenArtifact = loadFrozenArtifact ( { sessionId : sess , runtimeDir } ) ;
2026-06-07 05:41:07 +03:00
const stepPath = stepStatePath ( runtimeDir , sess ) ; // N3-shared guard формы sessionId
2026-06-05 04:21:05 +03:00
let stored = null ; try { stored = JSON . parse ( fs . readFileSync ( stepPath , 'utf8' ) ) ; } catch { }
2026-06-05 06:23:35 +03:00
const stepPtr = resolveStepPtr ( stored , frozenPlan ? . plan _id , key ? ( s ) => verifyStepState ( s , key ) : null ) ; // R-27 привязка + R-19 подпись
2026-06-14 06:11:11 +03:00
const r = runGate ( {
event , frozenPlan , frozenArtifact , stepPtr , key , escapeGrants , escapeConsumed ,
2026-06-04 16:48:49 +03:00
journal : ( entry ) => journalAppend ( { payload : entry , key , sessionId : sess , runtimeDir } ) ,
2026-06-14 06:11:11 +03:00
saveStep : ( n ) => fs . writeFileSync ( stepPath , JSON . stringify ( signStepState ( frozenPlan ? . plan _id ? ? null , n , key ) ) ) , // R-19: подписано
removeFrozenPlan : ( ) => removeFrozenPlan ( { sessionId : sess , runtimeDir } ) , // Фаза 5: чистое завершение
2026-06-04 16:48:49 +03:00
} ) ;
2026-06-10 04:28:53 +03:00
if ( r . block ) logGuardBlock ( event , 'М 2 Стена' , r . message ) ;
2026-06-04 16:48:49 +03:00
exitDecision ( { block : r . block , message : r . block ? ` [supreme-gate] ${ r . message } ` : undefined } ) ;
} catch {
2026-06-14 06:11:11 +03:00
// Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
2026-06-08 10:28:43 +03:00
const p = panicEscapeDecision ( event , escapeGrants , escapeConsumed ) ;
exitDecision ( { block : p . block , message : p . block ? '[supreme-gate] внутренняя ошибка — fail-CLOSED' : undefined } ) ;
2026-06-04 16:48:49 +03:00
}
}
import { fileURLToPath } from 'node:url' ;
const isCli = process . argv [ 1 ] && fileURLToPath ( import . meta . url ) === process . argv [ 1 ] ;
if ( isCli ) main ( ) ;