Files
portal/tools/enforce-supreme-gate.test.mjs
T
Дмитрий e1a6f26c06 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

336 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/enforce-supreme-gate.test.mjs
import { describe, it, expect } from 'vitest';
import { isSeed, SEED_SKILLS } from './enforce-supreme-gate.mjs';
import { decide, actionOf } from './enforce-supreme-gate.mjs';
import { runGate } from './enforce-supreme-gate.mjs';
import { decideMode } from './enforce-supreme-gate.mjs';
import { isObserveOnly } from './enforce-supreme-gate.mjs';
import { resolveStepPtr } from './enforce-supreme-gate.mjs';
import { resolveSessionId } from './enforce-supreme-gate.mjs';
import { signStepState, verifyStepState } from './enforce-supreme-gate.mjs';
import { stepStatePath } from './enforce-supreme-gate.mjs';
// N3-shared (2026-06-07 аудит M1-M4): путь файла указателя шага строится из sessionId
// (resolveSessionId(event), недоверенный источник) — тот же guard формы, что action-journal.
describe('N3: stepStatePath path-injection guard', () => {
it('нормальный sessionId → детерминированный путь', () => {
expect(stepStatePath('/rt', 'S1')).toBe('/rt/plan-step-S1');
expect(stepStatePath('/rt/', 'unknown')).toBe('/rt/plan-step-unknown');
});
it('traversal/слэш/точка/обратный слэш → throw', () => {
expect(() => stepStatePath('/rt', '../evil')).toThrow();
expect(() => stepStatePath('/rt', 'a/b')).toThrow();
expect(() => stepStatePath('/rt', 'a.b')).toThrow();
expect(() => stepStatePath('/rt', 'a\\b')).toThrow();
});
});
describe('R-19: указатель шага подписан (подмена ptr без ключа → сброс)', () => {
const SK = 'step-key';
it('валидно подписанный → ptr; подменённый ptr со старой подписью → 0', () => {
const signed = signStepState('P1', 3, SK);
expect(resolveStepPtr(signed, 'P1', (s) => verifyStepState(s, SK))).toBe(3);
const tampered = { ...signed, ptr: 9 };
expect(resolveStepPtr(tampered, 'P1', (s) => verifyStepState(s, SK))).toBe(0);
});
it('без verify-колбэка — обратная совместимость R-27 сохранена', () => {
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
});
});
describe('resolveSessionId — берёт session_id из события, не из env (R-28)', () => {
it('из event.session_id (канон Claude Code stdin)', () => {
expect(resolveSessionId({ session_id: 'S-42' }, {})).toBe('S-42');
});
it('фолбэк на env, затем unknown', () => {
expect(resolveSessionId({}, { CLAUDE_SESSION_ID: 'E-1' })).toBe('E-1');
expect(resolveSessionId(null, {})).toBe('unknown');
});
});
describe('resolveStepPtr — указатель шага привязан к plan_id (R-27)', () => {
it('тот же план → возвращает сохранённый ptr', () => {
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
});
it('другой план → сброс в 0 (перепечать не отматывает чужой указатель)', () => {
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P2')).toBe(0);
});
it('легаси (голое число) / null / без plan_id → 0', () => {
expect(resolveStepPtr(5, 'P1')).toBe(0);
expect(resolveStepPtr(null, 'P1')).toBe(0);
expect(resolveStepPtr({ ptr: 9 }, 'P1')).toBe(0);
});
it('нет текущего плана → 0', () => {
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, undefined)).toBe(0);
});
});
describe('isSeed (D12/D13 bootstrap exemption)', () => {
it('EnterPlanMode and AskUserQuestion are seeds', () => {
expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
});
it('Skill(writing-plans) / brainstorming / discovery-interview are seeds', () => {
for (const s of SEED_SKILLS) {
expect(isSeed({ name: 'Skill', input: { skill: s } })).toBe(true);
expect(isSeed({ name: 'Skill', input: { skill: 'superpowers:' + s } })).toBe(true);
}
});
it('a non-seed Skill is NOT a seed', () => {
expect(isSeed({ name: 'Skill', input: { skill: 'coder' } })).toBe(false);
});
it('mutating tools are not seeds', () => {
expect(isSeed({ name: 'Write', input: { file_path: 'x' } })).toBe(false);
expect(isSeed({ name: 'Bash', input: { command: 'rm x' } })).toBe(false);
});
});
describe('actionOf — поля объекта (F1, выровнено с B4)', () => {
it('берёт объект из доп. полей MCP-писателей (filename/uri/destination)', () => {
expect(actionOf({ name: 'mcp__fs__write', input: { filename: 'tools/foo.mjs' } }).object).toBe('tools/foo.mjs');
expect(actionOf({ name: 'mcp__x', input: { uri: 'a/b.txt' } }).object).toBe('a/b.txt');
expect(actionOf({ name: 'mcp__y', input: { destination: 'd/e.json' } }).object).toBe('d/e.json');
});
});
describe('decide — самодостаточная проверка печати артефакта (F2, защита-в-глубину)', () => {
it('битая печать артефакта при верном id → block (даже без обёртки decideMode)', () => {
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
const plan = { steps: [step], artifact_id: 'AID' };
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
const verifyImpl = (obj) => obj === plan; // план валиден, артефакт — НЕТ (битая печать)
const r = decide({
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
verifyImpl, normalize: (p) => p,
});
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/печать артефакта|seal/i);
});
it('R-31 split: артефакт проверяется ОТДЕЛЬНЫМ verifyArtifactImpl (не impl плана)', () => {
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
const plan = { steps: [step], artifact_id: 'AID' };
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
const r = decide({
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
verifyImpl: (o) => o === plan, verifyArtifactImpl: () => true, normalize: (p) => p,
});
expect(r.decision).toBe('allow');
});
});
const KEY = 'supreme-key';
const PLAN = {
plan_id: 'x', frozen_at: 1,
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }],
sig: 'unused-in-stub',
};
// stub verify: считаем план валидным если sig !== 'BAD'
const verifyStub = (p) => p && p.sig !== 'BAD';
const ctx = (over) => ({ key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(), ...over });
describe('supreme-gate decide()', () => {
it('seed passes even with no frozen plan', () => {
const r = decide(ctx({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.reason).toMatch(/seed/i);
});
it('default-deny when there is no frozen plan (non-seed)', () => {
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: null, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/нет замороженного плана|no frozen plan/i);
});
it('default-deny when frozen plan signature is invalid', () => {
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: { ...PLAN, sig: 'BAD' }, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/печать|seal|signature/i);
});
it('allow when action matches the current plan step', () => {
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.advanceTo).toBe(1);
});
it('block when action does NOT match the current step (not in plan)', () => {
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/не в плане|not in plan/i);
});
it('actionOf maps a tool event to {op, object}', () => {
expect(actionOf({ name: 'Write', input: { file_path: 'a.mjs' } })).toEqual({ op: 'Write', object: 'a.mjs' });
expect(actionOf({ name: 'Bash', input: { command: 'rm x' } })).toEqual({ op: 'Bash', object: 'rm x' });
expect(actionOf({ name: 'Skill', input: { skill: 'coder' } })).toEqual({ op: 'Skill', object: 'coder' });
});
});
describe('runGate (pure orchestration)', () => {
it('allow advances pointer and journals the action', () => {
const journaled = [];
const r = runGate({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
journal: (entry) => journaled.push(entry), saveStep: (n) => (journaled.savedStep = n),
});
expect(r.block).toBe(false);
expect(journaled).toHaveLength(1);
expect(journaled.savedStep).toBe(1);
});
it('block does NOT journal or advance', () => {
const journaled = [];
const r = runGate({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
journal: (e) => journaled.push(e), saveStep: () => {},
});
expect(r.block).toBe(true);
expect(journaled).toHaveLength(0);
});
});
const verifyStub2 = (p) => p && p.sig !== 'BAD';
const baseMode = (over) => ({ key: 'k', verifyImpl: verifyStub2, verifyArtifactImpl: verifyStub2, normalize: (p) => p.toLowerCase(), ...over });
describe('decideMode (C-7: разговорный vs реализационный)', () => {
it('разговорная фаза (нет плана-стройки): семя проходит', () => {
const r = decideMode(baseMode({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, frozenArtifact: null }));
expect(r.decision).toBe('allow');
expect(r.mode).toBe('conversational');
});
it('разговорная фаза: инструмент реализации блокируется (только думать/спрашивать)', () => {
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: null, frozenArtifact: null }));
expect(r.decision).toBe('block');
expect(r.mode).toBe('conversational');
expect(r.reason).toMatch(/разговорн|только думать|спрашивать/i);
});
it('бэкстоп: реализационный план без замороженного артефакта → блок', () => {
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/артефакт|разговор/i);
});
it('есть артефакт+план+совпавший шаг → стройка (передаём в decide)', () => {
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
const ART = { artifact_id: 'a', sig: 'ok' };
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLANL, frozenArtifact: ART, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.mode).toBe('implementation');
});
});
// stub classifyBash: readonly только для «безопасных» команд, редирект/писатель → не readonly (условие А)
const classifyBashStub = (cmd) => {
if (/[>|]|git\s+(config|gc|push|commit)|tee\b|\$\(/.test(cmd)) return { result: 'block', reason: 'mutating' };
if (/^(git\s+(status|log|diff|show)|grep|cat|ls)\b/.test(cmd)) return { result: 'allow', reason: 'readonly' };
return { result: 'block', reason: 'unknown' };
};
describe('isObserveOnly (зелёный проход = нет долговременного/исходящего эффекта, finding 9)', () => {
it('локальные смотрящие — да', () => {
for (const n of ['Read', 'Grep', 'Glob']) expect(isObserveOnly({ name: n })).toBe(true);
});
it('TodoWrite (эфемерный черновик) — да', () => {
expect(isObserveOnly({ name: 'TodoWrite', input: {} })).toBe(true);
});
it('read-only Bash по эффекту — да; редирект/писатель — НЕТ (условие А)', () => {
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } }, { classifyBash: classifyBashStub })).toBe(true);
expect(isObserveOnly({ name: 'Bash', input: { command: 'git log > evil.sh' } }, { classifyBash: classifyBashStub })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'git config x y' } }, { classifyBash: classifyBashStub })).toBe(false);
});
it('мутирующие — НЕТ', () => {
for (const n of ['Write', 'Edit', 'MultiEdit', 'Task']) expect(isObserveOnly({ name: n })).toBe(false);
});
it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => {
expect(isObserveOnly({ name: 'WebFetch' })).toBe(false);
expect(isObserveOnly({ name: 'WebSearch' })).toBe(false);
});
});
describe('decide() пропускает зелёный проход без шага плана', () => {
it('Read проходит без плана', () => {
const r = decide(ctx({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: null, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.reason).toMatch(/observe|смотр|зелён/i);
});
});
const ARTID = 'aid-1';
const PLAN_REF = { plan_id: 'p', artifact_id: ARTID, frozen_at: 1,
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
const ART_OK = { artifact_id: ARTID, sections: { '§1': 'teal' }, sig: 'ok' };
describe('decide() закрытая дверь + привязка к версии артефакта (C-5)', () => {
it('совпавший шаг + ссылка резолвится + версия совпала → allow', () => {
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
frozenPlan: PLAN_REF, frozenArtifact: ART_OK, stepPtr: 0 }));
expect(r.decision).toBe('allow');
});
it('ссылка шага не резолвится в артефакте → block', () => {
const planBadRef = { ...PLAN_REF, steps: [{ ...PLAN_REF.steps[0], ref: '§9' }] };
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
frozenPlan: planBadRef, frozenArtifact: ART_OK, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/не резолвится|закрыт|C-5/i);
});
it('версия артефакта не та, под которую печатали план → block', () => {
const otherArt = { ...ART_OK, artifact_id: 'aid-2' };
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
frozenPlan: PLAN_REF, frozenArtifact: otherArt, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/версия|artifact|пере-печать/i);
});
});
// F-A (аудит 2026-06-07): зелёный проход Bash через НАСТОЯЩИЙ classifyBashCommand.
// reason 'whitelisted reading command(s)' схлопывается, если в цепочке есть хоть
// один читатель — тогда whitelisted-мутатор за `cat x &&` проскакивал зелёным
// проходом (composer pint → переписать исходники, migrate:fresh → дроп БД,
// node <script> → произвольный JS). Эти тесты используют дефолтный classifyBash.
describe('isObserveOnly — F-A: цепочка читатель+мутатор НЕ зелёный проход (real classify)', () => {
it('читатель + whitelisted-мутатор → НЕ observe-only', () => {
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && composer pint' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat README && php artisan migrate:fresh' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && node evil.mjs' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'composer pint && grep x README' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && pest' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && npm test' } })).toBe(false);
});
it('чистая цепочка читателей + одиночный readonly-git остаются зелёными', () => {
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && grep b a' } })).toBe(true);
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } })).toBe(true);
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README' } })).toBe(true);
});
});
// F-B (аудит 2026-06-07): при плане без валидного артефакта observe-only не должен
// душиться (инвариант finding 9 + согласованность с decide()).
describe('decideMode — F-B: observe-only проходит даже при плане без артефакта', () => {
it('Read при плане + пропавшем артефакте → allow (не блок)', () => {
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
const r = decideMode(baseMode({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
expect(r.decision).toBe('allow');
expect(r.mode).toBe('conversational');
});
it('Write при плане + пропавшем артефакте по-прежнему блокируется (бэкстоп цел)', () => {
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
expect(r.decision).toBe('block');
});
});
// F-C (аудит 2026-06-07): шаг с ref обязан резолвиться в опечатанном артефакте
// НЕЗАВИСИМО от того, привязан ли план к artifact_id (закрытая дверь не
// выключается планом без artifact_id).
describe('decide — F-C: ref при плане без artifact_id всё равно требует артефакт', () => {
it('шаг с ref + план без artifact_id + нет артефакта → block', () => {
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/ссылк|закрыт|артефакт|C-5/i);
});
it('шаг без ref + план без artifact_id → allow (легаси не сломан)', () => {
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
expect(r.decision).toBe('allow');
});
});