a0a73d8d3a
Δ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>
236 lines
18 KiB
JavaScript
236 lines
18 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 } 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();
|