Files
portal/tools/plan-lock.test.mjs
T
Дмитрий 69e20099db fix(router-mentor): sharp-edges audit M1-M4 — close 8 misuse-resistance holes
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к
неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке).
Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD:

M3:
- coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces
  "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover:
  точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry.
- router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как
  «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite.
- round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии
  делили один счётчик-бакет. Теперь managed требует непустую строку.
- router-learning-queue G: повторное approve уже-решённого id повторно клало запись в
  фонд (дубль). applyApprovalBatch: переводит только status==='pending'.

M2:
- plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь
  не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего.

M4 (инертна; чистые fail-closed правки кода, корректны и при включении):
- judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null).
  Теперь не крашит, считается халтурой (безопасная сторона).
- judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO
  только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk).
- judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked
  (undefined от упавшей проверки = fail-open). Теперь снять может только явный false.

Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий).

Осознанно НЕ менялось (без призраков):
- M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт
  (тест 18-19), инъективен на enum-доменах без пробелов.
- M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed
  (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет.
- M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:24:21 +03:00

190 lines
9.7 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 } 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);
});
});