Files
portal/tools/enforce-supreme-gate.mjs
T
Дмитрий 84231a1470 feat(board): live source for guard board escapes/blocks (D-3)
Доска «кто на посту» (STATUS.md §7) теперь показывает реальные недавние
escape владельца и блоки машин М1–М6 вместо хардкода []/[].

- new tools/guard-block-log.mjs: logGuardBlock (best-effort, fail-quiet,
  Node fs append в guard-blocks-<sess>.jsonl) + loadRecentBlocks/
  loadRecentEscapes (скан session-файлов runtime, окно 24ч + cap 10, ts→ISO).
- проводка logGuardBlock в block-ветку main() 9 машинных хуков (floor /
  supreme-gate / judge-gate / snapshot / read-path-deny / mcp-classification /
  normative-content-rules / verify-gate / criterion-gate). Логгер вызывается
  ПОСЛЕ решения, не влияет на block; decide() pure не тронут.
- status-md-generator CLI: recentEscapes/recentBlocks из читателей вместо []/[].

До флипа Фазы 8 доска показывает 0/0 (хуки не зарегистрированы — данных нет);
реальная польза — пост-флип наблюдаемость.

TDD: guard-block-log.test (6) + 9 структурных wiring-тестов + 1 board-тест.
Гейт закрытия: sharp-edges (промежуточный по 9 хукам + читатели) +
variant-analysis (все block-ветки покрыты, иных источников нет). Регрессия
tools-only 3465 passed / 2 skipped / 0 failed (было 3449+2skip). 0 регрессий.

Plan: docs/superpowers/plans/2026-06-10-guard-board-live-source.md
2026-06-10 04:28:53 +03:00

301 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* enforce-supreme-gate — Верховный хук (Машина 2): default-deny на ВСЕ действия,
* пропуск только при шаге замороженного плана. Матчер = '*' (регистрация в
* settings.json — owner-шаг). Семена D12/D13 пропускаются всегда (иначе стену
* нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца).
*/
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves, treeLeafAt, validatePlanTree } from './plan-lock.mjs';
import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.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 { classifyDestructive } from './classify-destructive.mjs';
import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
import { logGuardBlock } from './guard-block-log.mjs';
// Узкий технический 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;
}
// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
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}`;
}
/**
* Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие
* совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится
* в опечатанном артефакте той же версии (закрытая дверь C-5). Иначе block.
* @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 (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 стена всё равно не двигает указатель на снос.
if (classifyDestructive(actionOf(toolUse).object).floor) {
return { decision: 'block', reason: `шаг ${step.n}: разрушительное in-plan действие — пол требует двери владельца (стена не двигает указатель, Δ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)' }; }
return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo };
}
/**
* Выбор режима стены (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) — указатель не двигается' };
}
if (!frozenPlan) {
if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (разговорный режим)' };
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)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (бэкстоп: артефакт не опечатан)' };
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') {
if (isSeed(toolUse) || isObserveOnly(toolUse)) {
return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (не live-block-печать, SE-2)' };
}
return { decision: 'block', mode: 'conversational', reason: 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)' };
}
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, 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 };
}
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);
}
return { block: r.decision === 'block', message: 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 } = 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: подписано
});
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();