From 2b0c28e59fc5db595bcb5740f00ef910cb3210f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 9 Jun 2026 05:12:38 +0300 Subject: [PATCH] feat(supreme-gate): tree leaf resolution plus advance flag for R-08 waves --- tools/enforce-supreme-gate.mjs | 21 ++++++++++--- tools/enforce-supreme-gate.test.mjs | 48 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 8117f48e..8c149ceb 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -6,7 +6,8 @@ * нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца). */ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; -import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves } from './plan-lock.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'; @@ -135,8 +136,11 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 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: 'план исчерпан — нет шага под это действие' }; + // 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: @@ -165,7 +169,14 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 if (classifyDestructive(actionOf(toolUse).object).floor) { return { decision: 'block', reason: `шаг ${step.n}: разрушительное in-plan действие — пол требует двери владельца (стена не двигает указатель, Δ7)` }; } - return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 }; + // 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 }; } /** @@ -213,7 +224,7 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verif } return { block: false, message: r.reason }; } - if (r.decision === 'allow' && typeof r.advanceTo === 'number') { + if (r.decision === 'allow' && r.advance === true) { // Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения — // честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ // разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 79710e80..8c86a73c 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -479,3 +479,51 @@ describe('R-08: сериализованный указатель (целое И expect(resolve3({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3); }); }); + +// ── R-08: decide ходит по дереву; SE-1 явный advance; flat-регрессия ── +import { decide as decideTree } from './enforce-supreme-gate.mjs'; +import { freezePlan as fp4, verifyFrozenPlan as vfp4 } from './plan-lock.mjs'; + +const K4 = 'k4'; +const TREE4 = [ + { n: 1, op: 'Write', object: 'tools/a.mjs' }, + { n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'tools/x.mjs' }, { n: '2.2', op: 'Bash', object: 'npx vitest' }] }, +]; +const norm4 = (p) => p.toLowerCase(); + +describe('R-08 decide — дерево', () => { + const plan = fp4({ steps: TREE4, key: K4, nowMs: 1 }); + it('указатель 0 (целое) → матч первого листа, advance:true', () => { + const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 }); + expect(r.decision).toBe('allow'); + expect(r.advance).toBe(true); + }); + it('указатель 1 (контейнер) → спуск, матчит лист 2.1, следующий 2.2', () => { + const r = decideTree({ toolUse: { name: 'Edit', input: { file_path: 'tools/x.mjs' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 }); + expect(r.decision).toBe('allow'); + expect(r.advanceTo).toEqual([1, 1]); + }); + it('действие не лист текущей позиции → block', () => { + const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 }); + expect(r.decision).toBe('block'); + }); + it('контейнер сам действие не матчит (SE-2): Write на метку контейнера → block', () => { + const r = decideTree({ toolUse: { name: 'Write', input: { file_path: '2' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 }); + expect(r.decision).toBe('block'); + }); + it('битое дерево (контейнер с op) → block (validatePlanTree)', () => { + const bad = fp4({ steps: [{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }], key: K4, nowMs: 1 }); + const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'a' } }, frozenPlan: bad, stepPtr: 0, key: K4, normalize: norm4 }); + expect(r.decision).toBe('block'); + }); +}); + +describe('R-08 flat-регрессия decide (SE-7)', () => { + const flat = fp4({ steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], key: K4, nowMs: 1 }); + it('плоский план: advanceTo целое (как раньше), advance:true', () => { + const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: flat, stepPtr: 0, key: K4, normalize: norm4 }); + expect(r.decision).toBe('allow'); + expect(r.advanceTo).toBe(1); + expect(r.advance).toBe(true); + }); +});