feat(supreme-gate): tree leaf resolution plus advance flag for R-08 waves

This commit is contained in:
Дмитрий
2026-06-09 05:12:38 +03:00
parent 16e0c1db09
commit 2b0c28e59f
2 changed files with 64 additions and 5 deletions
+16 -5
View File
@@ -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), указатель НЕ двигается. Сверку «произошло ровно записанное» делает
+48
View File
@@ -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);
});
});