Files
portal/tools/plan-lock.test.mjs
T
Дмитрий c13a1a56a1 fix(router-mentor): close sessionId path-traversal class across M1-M4
Аудит M1-M4 (audit-context-building) нашёл непоследовательность guard формы
sessionId: N3-фикс защитил только action-journal.paths() (M1), а 4 sibling-
строителя пути из event.session_id (недоверенный источник) остались без проверки.

Единый экспорт assertSafeSessionId (action-journal.mjs, переиспользует SESSION_ID_RE
N3) применён во всех точках машин:
- M1 action-journal.paths() — рефактор на общий guard (поведение N3 сохранено)
- M4 judge-subrun-journal.paths() — guard добавлен (канал прилежности судьи F1)
- M2 plan-lock.planPath + artifactPath — guard добавлен
- M2 enforce-supreme-gate — экспортируемый guarded stepStatePath, применён в main()

TDD RED-GREEN на каждом файле. Регрессия tools-only 2540 passed + 2 skip (+10).
Серьёзность класса — низкая / защита-в-глубину (sessionId harness-controlled),
закрыт ради консистентности (был 1 из 5).

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

183 lines
9.2 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);
});
});
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);
});
});