// 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 } from './plan-lock.mjs'; 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)) }; } 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('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); }); });