Files
brain/tools/plan-lock.test.mjs
T
2026-06-17 10:53:25 +03:00

372 lines
20 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.
// tools/plan-lock.test.mjs
import { describe, it, expect } from 'vitest';
import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs';
import { actionMatchesStep, nextStep } from './plan-lock.mjs';
import { saveFrozenPlan, loadFrozenPlan, removeFrozenPlan } from './plan-lock.mjs';
describe('freezePlan delivery', () => {
const KEY = 'k-deliv';
it('user-result попадает в подписанную печать и верифицируется', () => {
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
expect(p.delivery).toBe('user-result');
expect(verifyFrozenPlan(p, KEY)).toBe(true);
});
it('internal (по умолчанию) НЕ добавляет поле — старые печати байт-идентичны', () => {
const a = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], key: KEY, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'internal', key: KEY, nowMs: 1 });
expect('delivery' in a).toBe(false);
expect(a.sig).toBe(b.sig);
});
it('подмена delivery ломает подпись', () => {
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
expect(verifyFrozenPlan({ ...p, delivery: 'internal' }, KEY)).toBe(false);
});
});
describe('removeFrozenPlan (Фаза 5 — чистое завершение: стена снимает печать)', () => {
const fsWithUnlink = () => {
const s = new Map();
return {
s,
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
writeFileSync: (p, d) => s.set(String(p), String(d)),
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); },
unlinkSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } s.delete(String(p)); },
};
};
it('удаляет файл печати → loadFrozenPlan → null', () => {
const fs = fsWithUnlink();
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).not.toBe(null);
removeFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).toBe(null);
});
it('нет файла → no-op (не бросает на ENOENT)', () => {
const fs = fsWithUnlink();
expect(() => removeFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).not.toThrow();
});
it('traversal-sessionId бросает (path-guard)', () => {
expect(() => removeFrozenPlan({ sessionId: '../evil', runtimeDir: '/rt', fsImpl: fsWithUnlink() })).toThrow();
});
});
import { reconcileJournalToPlan } from './plan-lock.mjs';
import { freezeArtifact, verifyFrozenArtifact, artifactId } from './plan-lock.mjs';
import { saveFrozenArtifact, loadFrozenArtifact } from './plan-lock.mjs';
import { refResolves } from './plan-lock.mjs';
const KEY = 'plan-lock-test-key';
const STEPS = [
{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'создать модуль' },
{ n: 2, op: 'Bash', object: 'npx vitest run foo', intent: 'прогнать тест' },
];
const ART = { sections: { '§1': 'палитра teal', '§2': 'шрифт Inter' }, goal: 'лендинг' };
describe('доменное разделение печатей (R-31)', () => {
it('замороженный план НЕ проходит как артефакт, и наоборот', () => {
const plan = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const art = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(verifyFrozenPlan(plan, KEY)).toBe(true);
expect(verifyFrozenArtifact(art, KEY)).toBe(true);
expect(verifyFrozenArtifact(plan, KEY)).toBe(false); // печать плана ≠ печать артефакта
expect(verifyFrozenPlan(art, KEY)).toBe(false);
});
});
function memFs() {
const s = new Map();
return { s,
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
writeFileSync: (p, d) => s.set(String(p), String(d)),
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); } };
}
describe('freezePlan / verifyFrozenPlan', () => {
it('freezePlan returns a signed plan with stable plan_id', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1000 });
expect(p.plan_id).toMatch(/^[0-9a-f]{64}$/);
expect(p.frozen_at).toBe(1000);
expect(p.sig).toMatch(/^[0-9a-f]{64}$/);
expect(p.steps).toHaveLength(2);
});
it('plan_id is deterministic over steps content', () => {
expect(planId(STEPS)).toBe(planId(STEPS.map((s) => ({ ...s }))));
});
it('verifyFrozenPlan true for an intact signed plan', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(verifyFrozenPlan(p, KEY)).toBe(true);
});
it('verifyFrozenPlan false when a step is tampered (re-seal required)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const tampered = { ...p, steps: [{ ...p.steps[0], object: 'tools/evil.mjs' }, p.steps[1]] };
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
});
it('verifyFrozenPlan false for unsigned / wrong key (no seal → fiction)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(verifyFrozenPlan({ ...p, sig: undefined }, KEY)).toBe(false);
expect(verifyFrozenPlan(p, 'other-key')).toBe(false);
});
});
describe('actionMatchesStep (deterministic, P15-a/e)', () => {
const step = { n: 1, op: 'Write', object: 'tools/foo.mjs' };
const norm = (p) => p.replace(/\\/g, '/').toLowerCase(); // stub pathNormalize
it('matches when op AND object both equal (after normalize)', () => {
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/FOO.mjs' }, { normalize: norm })).toBe(true);
});
it('does NOT match when op differs (operation axis, P15-e)', () => {
expect(actionMatchesStep(step, { op: 'Edit', object: 'tools/foo.mjs' }, { normalize: norm })).toBe(false);
});
it('does NOT match when object differs', () => {
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/bar.mjs' }, { normalize: norm })).toBe(false);
});
it('Bash matches by normalized command', () => {
const bstep = { n: 1, op: 'Bash', object: 'npx vitest run foo' };
expect(actionMatchesStep(bstep, { op: 'Bash', object: 'npx vitest run foo' })).toBe(true);
});
// fix: tools/plan-lock.mjs (F5, аудит M1-M4) — пустой object шага не должен быть джокером
it('шаг с пустым файловым object НЕ матчит действие с пустым object', () => {
expect(actionMatchesStep({ n: 1, op: 'Write', object: '' }, { op: 'Write', object: '' }, { normalize: norm })).toBe(false);
});
it('шаг Bash с пустой командой НЕ матчит пустую команду', () => {
expect(actionMatchesStep({ n: 1, op: 'Bash', object: '' }, { op: 'Bash', object: '' })).toBe(false);
});
});
describe('nextStep (ordered pointer)', () => {
const steps = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
it('returns the step at the current pointer', () => {
expect(nextStep(steps, 0)).toEqual(steps[0]);
expect(nextStep(steps, 1)).toEqual(steps[1]);
});
it('returns null past the end', () => {
expect(nextStep(steps, 2)).toBe(null);
});
});
describe('frozen plan persistence', () => {
it('save then load round-trips and stays verifiable', () => {
const fs = memFs();
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
saveFrozenPlan({ plan: p, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const loaded = loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyFrozenPlan(loaded, KEY)).toBe(true);
expect(loaded.plan_id).toBe(p.plan_id);
});
it('loadFrozenPlan returns null when no plan frozen', () => {
expect(loadFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: memFs() })).toBe(null);
});
});
describe('sealed-plan production Task 3 — judge_mode in sig + atomic persist', () => {
it('freezePlan carries judge_mode in signed base (VA-2)', () => {
const key = 'k';
const p = freezePlan({ steps: [{ op: 'Edit', object: 'a', ref: 'r' }], artifactId: 'aid', judgeMode: 'shadow', key });
expect(p.judge_mode).toBe('shadow');
expect(verifyFrozenPlan(p, key)).toBe(true);
const tampered = { ...p, judge_mode: 'live-block' };
expect(verifyFrozenPlan(tampered, key)).toBe(false); // judge_mode в подписи
});
it('saveFrozenPlan writes atomically via temp+rename (SE-4)', () => {
const calls = [];
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 's', runtimeDir: '/r', fsImpl });
expect(calls.some((c) => c[0] === 'rename')).toBe(true); // прошёл через rename
});
it('saveFrozenArtifact also writes atomically via temp+rename (SE-4/VA-3)', () => {
const calls = [];
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
saveFrozenArtifact({ artifact: { artifact_id: 'a' }, sessionId: 's', runtimeDir: '/r', fsImpl });
expect(calls.some((c) => c[0] === 'rename')).toBe(true);
});
});
describe('reconcileJournalToPlan (P25-d)', () => {
const steps = [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Bash', object: 'npx vitest' }];
it('all journal actions map to a plan step → ok', () => {
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'npx vitest' }];
expect(reconcileJournalToPlan(journal, steps).ok).toBe(true);
});
it('an orphan action (no matching step) is flagged', () => {
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'rm -rf /' }];
const r = reconcileJournalToPlan(journal, steps);
expect(r.ok).toBe(false);
expect(r.orphans).toHaveLength(1);
expect(r.orphans[0].object).toBe('rm -rf /');
});
});
describe('freezeArtifact / verifyFrozenArtifact (вторая печать, C-10)', () => {
it('freezeArtifact returns a signed artifact with stable id', () => {
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(a.artifact_id).toMatch(/^[0-9a-f]{64}$/);
expect(a.sig).toMatch(/^[0-9a-f]{64}$/);
});
it('verify true for intact, false when a section tampered (re-seal required)', () => {
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(verifyFrozenArtifact(a, KEY)).toBe(true);
const tampered = { ...a, sections: { ...a.sections, '§1': 'evil' } };
expect(verifyFrozenArtifact(tampered, KEY)).toBe(false);
});
it('artifactId deterministic over content', () => {
expect(artifactId(ART)).toBe(artifactId({ ...ART }));
});
it('artifact save/load round-trips and stays verifiable', () => {
const fs = memFs();
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
saveFrozenArtifact({ artifact: a, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const loaded = loadFrozenArtifact({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyFrozenArtifact(loaded, KEY)).toBe(true);
expect(loaded.artifact_id).toBe(a.artifact_id);
});
});
describe('refResolves (закрытая дверь, C-5)', () => {
const ARTSEC = { sections: { '§1': 'teal', '§2': 'Inter' } };
it('ссылка шага резолвится в опечатанном артефакте', () => {
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§1' }, ARTSEC)).toBe(true);
});
it('несуществующая ссылка → не резолвится', () => {
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§9' }, ARTSEC)).toBe(false);
});
it('шаг без ref (простой/legacy) → резолв не требуется (true)', () => {
expect(refResolves({ n: 1, op: 'Write', object: 'x' }, ARTSEC)).toBe(true);
});
});
// N3-shared (2026-06-07 аудит M1-M4): planPath/artifactPath строят путь из sessionId
// (event.session_id, недоверенный источник) — тот же guard формы, что action-journal.
describe('N3: plan-lock path-injection guard (planPath + artifactPath)', () => {
it('saveFrozenPlan с traversal-sessionId бросает (ничего не пишет)', () => {
const fs = memFs();
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(() => saveFrozenPlan({ plan: p, sessionId: '../evil', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(fs.s.size).toBe(0);
});
it('loadFrozenPlan/saveFrozenArtifact/loadFrozenArtifact с traversal/слэшем/точкой бросают', () => {
const fs = memFs();
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(() => loadFrozenPlan({ sessionId: '../../etc/passwd', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => saveFrozenArtifact({ artifact: a, sessionId: 'a/b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => loadFrozenArtifact({ sessionId: 'a.b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
});
it('нормальный sessionId по-прежнему работает (план + артефакт)', () => {
const fs = memFs();
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
saveFrozenPlan({ plan: p, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
expect(loadFrozenPlan({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }).plan_id).toBe(p.plan_id);
});
});
// 5.1 (Машина 5 Пакет 5, Блок 3) — plan-lock присваивает каждому шагу детерминированный
// criterion_id ИЗ ЗАПЕЧАТАННОГО содержимого: id воспроизводим (функция содержания шага),
// запечатан подписью плана (нельзя выдумать на демо-этапе — подмена ломает verifyFrozenPlan).
import { stepCriterionId, sealedCriterionIds } from './plan-lock.mjs';
describe('plan-lock sealed criterion_id (Пакет 5, 5.1)', () => {
it('freezePlan присваивает каждому шагу criterion_id (64-hex)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
for (const s of p.steps) {
expect(s.criterion_id).toMatch(/^[0-9a-f]{64}$/);
}
});
it('criterion_id детерминирован: две заморозки тех же шагов → те же id', () => {
const a = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const b = freezePlan({ steps: STEPS, key: KEY, nowMs: 2 });
expect(a.steps.map((s) => s.criterion_id)).toEqual(b.steps.map((s) => s.criterion_id));
});
it('id воспроизводим из содержимого шага (stepCriterionId)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(p.steps[0].criterion_id).toBe(stepCriterionId(STEPS[0]));
});
it('разное содержимое шага → разный id (подмена смысла при сохранении id невозможна)', () => {
expect(stepCriterionId({ n: 1, op: 'Write', object: 'a' })).not.toBe(
stepCriterionId({ n: 1, op: 'Write', object: 'b' }),
);
});
it('stepCriterionId идемпотентен на уже-обогащённом шаге (criterion_id исключён из хеша)', () => {
const cid = stepCriterionId(STEPS[0]);
expect(stepCriterionId({ ...STEPS[0], criterion_id: cid })).toBe(cid);
});
it('sealedCriterionIds возвращает запечатанный набор id плана', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(sealedCriterionIds(p).sort()).toEqual(p.steps.map((s) => s.criterion_id).sort());
});
it('подмена criterion_id шага после печати → verifyFrozenPlan false (id запечатан подписью)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const tampered = { ...p, steps: [{ ...p.steps[0], criterion_id: 'deadbeef'.repeat(8) }, p.steps[1]] };
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
});
});
// ── R-08: дерево листьев / лист по указателю / валидация дерева ──
import { treeLeaves, treeLeafAt, validatePlanTree } from './plan-lock.mjs';
const R08TREE = [
{ n: 1, op: 'Write', object: 'a' },
{ n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'x' }, { n: '2.2', op: 'Bash', object: 'y' }] },
{ n: 3, op: 'Write', object: 'c' },
];
describe('treeLeaves (depth-first, V1/V2 вход)', () => {
it('дерево → все листья по порядку', () => {
expect(treeLeaves(R08TREE).map((s) => s.n)).toEqual([1, '2.1', '2.2', 3]);
});
it('плоский план → сами шаги (идентичность)', () => {
const flat = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
expect(treeLeaves(flat)).toEqual(flat);
});
});
describe('treeLeafAt (один лист по указателю, SE-2)', () => {
it('целое 0 → первый лист', () => {
expect(treeLeafAt(R08TREE, 0).n).toBe(1);
});
it('целое 1 (контейнер) → спуск к первому листу, не контейнер', () => {
const leaf = treeLeafAt(R08TREE, 1);
expect(leaf.n).toBe('2.1');
expect(leaf.substeps).toBeUndefined();
});
it('массив [1,1] → 2.2', () => {
expect(treeLeafAt(R08TREE, [1, 1]).n).toBe('2.2');
});
it('за концом → null', () => {
expect(treeLeafAt(R08TREE, 9)).toBe(null);
});
it('битый указатель → null', () => {
expect(treeLeafAt(R08TREE, 'x')).toBe(null);
});
});
describe('validatePlanTree (SE-2/SE-4 fail-CLOSED)', () => {
it('валидное дерево → ok', () => {
expect(validatePlanTree(R08TREE).ok).toBe(true);
});
it('пустой контейнер → не ok (SE-4)', () => {
expect(validatePlanTree([{ n: 1, substeps: [] }]).ok).toBe(false);
});
it('контейнер с op/object/ref → не ok (SE-2)', () => {
expect(validatePlanTree([{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }]).ok).toBe(false);
});
it('substeps не массив → не ok', () => {
expect(validatePlanTree([{ n: 1, substeps: 'x' }]).ok).toBe(false);
});
it('превышение глубины → не ok', () => {
let node = { n: 'leaf', op: 'Write', object: 'z' };
for (let i = 0; i < 12; i++) node = { substeps: [node] };
expect(validatePlanTree([node]).ok).toBe(false);
});
});
describe('freezePlan покрывает substeps подписью + criterion_id рекурсия (I9/I10)', () => {
it('правка под-шага ломает sig', () => {
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
const tampered = { ...p, steps: [p.steps[0], { ...p.steps[1], substeps: [{ ...p.steps[1].substeps[0], object: 'evil' }, p.steps[1].substeps[1]] }, p.steps[2]] };
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
});
it('под-шаги получают criterion_id', () => {
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
expect(p.steps[1].substeps[0].criterion_id).toMatch(/^[0-9a-f]{64}$/);
});
});