Files
portal/tools/enforce-supreme-gate.mjs
T
Дмитрий a0a73d8d3a feat(m5): Пакет 8 — Δ3 честный двухтакт reconcile (8.1 пред-запись + 8.2 реконсилер)
Δ3: убрано обещание «атомарно на исполнении» (PreToolUse не видит факт). Достижимый максимум —
два такта:
- 8.1 (runGate): пред-запись НАМЕРЕНИЯ в журнал ДО allow. Журнал вернул false ИЛИ бросил →
  стена НЕ разрешает (block), указатель не двигается («нет записи → нет действия», явно).
  Backward-compat: push → length (truthy) = успех; только явный false/throw → block.
- 8.2 (enforce-reconcile.mjs, новый): PostToolUse-сверка. reconcileAction — исполненное
  действие без пред-записи → action-without-record (возможен обход). findOrphanIntents —
  пред-записи без исполнения → record-without-action. WARN-уровень (не блок: PreToolUse-пол
  уже отработал, PostToolUse не отменяет исполненное). Чистые функции + fail-quiet I/O main.

+2 (supreme-gate runGate) +5 (reconcile) тестов. Полная tools-only регрессия 2788 + 2 skip
(0 регрессий). Машина 5 (Пакеты 5-8) собрана полностью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:29:48 +03:00

236 lines
18 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 } from './plan-lock.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';
// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
// открывают одобрение. Обоснование — D12/D13.
export const SEED_SKILLS = ['writing-plans', 'brainstorming', 'discovery-interview'];
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: битая/отсутствующая подпись → сброс
const n = Number(stored.ptr);
return Number.isInteger(n) && n >= 0 ? n : 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) — требуется заново одобрить план' };
const step = nextStep(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)` };
}
return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 };
}
/**
* Выбор режима стены (C-7). Нет замороженного плана-стройки → разговорный режим:
* пропускаем только семена/зелёный проход, остальное block. Есть план →
* реализационный режим, НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10).
*/
export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
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)' };
}
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 }) {
const toolUse = { name: event.tool_name, input: event.tool_input };
const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize });
if (r.decision === 'allow' && typeof r.advanceTo === 'number') {
// Δ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 };
}
async function main() {
try {
const 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
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,
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: подписано
});
exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
} catch {
exitDecision({ block: true, message: '[supreme-gate] внутренняя ошибка — fail-CLOSED' });
}
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();