#!/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