Files
portal/tools/enforce-supreme-gate.test.mjs
T
Дмитрий 0289e93c0e feat(gate1+se2): wire spec-judging (Гейт-1) + fail-closed wall whitelist (SE-2)
М7 Фаза 8 — последние 2 хвоста до флипа. Inline TDD; спека+план одобрены; 2 адверсариальных прохода.

Гейт-1 (печать артефакта в рантайме):
- extractGate1Product (enforce-judge-gate.mjs): детект записи specs/*.md (Write-only) → судить
  линзами gate1 (движок уже поддерживает). runJudgeGate: спека→gate1, иначе план→gate2.
- judged_hash от СЫРОГО content (SD-1, разбор #1 High): trimmed source_sha не сошёлся бы с
  печатью sealOnWiredGo → артефакт НИКОГДА не печатался бы. Один канон судья→печать.
- extractGate2Product не тронут (Гейт-2 = планы, регрессия зелёная).

SE-2 (стена не чтит shadow):
- decideMode (enforce-supreme-gate.mjs): fail-closed whitelist — энфорсмент ТОЛЬКО при
  judge_mode==='live-block' на ОБЕИХ печатях; shadow/null/опечатка → разговорный (мутаторы block).
  Разбор #1 (VA-a/SE-a): убрана единственная fail-OPEN сверка; опечатка режима безопасна.
- мигрированы 3 enforce-теста стены на judge_mode:'live-block'.

Тесты: gate1 10, SE-2 6; регрессия tools-only 3443 passed / 2 skip / 0 регрессий.
Гейт-1 тесты — отдельный файл enforce-judge-gate-gate1.test.mjs (real-test-verifier блокирует
import-only Edit существующего теста). Печать в рантайме до флипа НЕ производится.
2026-06-09 19:05:17 +03:00

567 lines
34 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();
});
});
import { panicEscapeDecision } from './enforce-supreme-gate.mjs';
import { canonicalAction } from './escape-grant.mjs';
describe('supreme-gate panicEscapeDecision (M7 Фаза 2, правило 7б — сетап main бросил)', () => {
const now = 1_000_000;
const ev = { tool_name: 'Bash', tool_input: { command: 'git push --force' } };
it('сетап бросил + матч escape-грант → block:false', () => {
const action = canonicalAction('Bash', { command: 'git push --force' });
expect(panicEscapeDecision(ev, [{ action, ts: now - 1000 }], [], now).block).toBe(false);
});
it('сетап бросил БЕЗ escape → block:true (fail-CLOSED)', () => {
expect(panicEscapeDecision(ev, [], [], now).block).toBe(true);
});
});
describe('supreme-gate decideMode escape ДО бросающего verify (M7 Фаза 2, правило 7а-ordering)', () => {
const now = 1_000_000;
it('матч escape-грант + БРОСАЮЩИЙ verify* → allow mode:escape (verify не вызван)', () => {
const toolUse = { name: 'Bash', input: { command: 'rm -rf /' } };
const action = canonicalAction('Bash', { command: 'rm -rf /' });
const r = decideMode({
toolUse, frozenPlan: { steps: [], plan_id: 'p' }, frozenArtifact: { artifact_id: 'a' },
key: 'k', escapeGrants: [{ action, ts: now - 1000 }], escapeConsumed: [], now,
verifyImpl: () => { throw new Error('verify boom'); },
verifyArtifactImpl: () => { throw new Error('artifact boom'); },
});
expect(r.decision).toBe('allow');
expect(r.mode).toBe('escape');
});
});
describe('SEED_SKILLS — реактивные дисциплинарные навыки seed-allowed (M7 Фаза 3, SE-K)', () => {
const sk = (skill) => ({ name: 'Skill', input: { skill } });
for (const s of ['systematic-debugging', 'test-driven-development', 'requesting-code-review', 'verification-before-completion']) {
it(`isSeed для ${s} → true (реактивный навык не рубится «вне плана»)`, () => {
expect(isSeed(sk(`superpowers:${s}`))).toBe(true);
});
}
it('не-seed навык (coder) → false (регресс — не все навыки seed)', () => {
expect(isSeed(sk('coder'))).toBe(false);
});
});
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, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, 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);
});
// 8.1 (Δ3) — enforce-supreme-gate: пред-запись НАМЕРЕНИЯ в журнал ДО allow. Нет пред-записи
// (журнал вернул false ИЛИ бросил) → стена НЕ разрешает (block), шаг не двигается.
it('Δ3 (8.1): journal вернул false → block, шаг НЕ двигается', () => {
let saved = null;
const r = runGate({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
journal: () => false, saveStep: (n) => { saved = n; },
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/пред-запис|нет записи/i);
expect(saved).toBe(null);
});
it('Δ3 (8.1): journal бросил исключение → block (fail-closed на пред-записи)', () => {
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: () => { throw new Error('io'); }, saveStep: () => {},
});
expect(r.block).toBe(true);
});
});
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, judge_mode: 'live-block', steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
const ART = { artifact_id: 'a', judge_mode: 'live-block', 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');
});
});
// enforce-supreme-gate.mjs — G-1 α: сквозной floor_escape (allow БЕЗ advanceTo, M6 Пакет 4b)
describe('supreme-gate escape (M6 G-1 α)', () => {
const now = 1000;
it('разрушительное с совпавшим floor_escape (разговорный режим) → allow без advanceTo', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }], escapeConsumed: [], now });
expect(r.decision).toBe('allow');
expect(r.advanceTo).toBeUndefined();
});
it('без пропуска разговорный мутатор → block (поведение М2 не изменилось)', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [], escapeConsumed: [], now });
expect(r.decision).toBe('block');
});
it('погашенный пропуск (one-shot) → block', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }],
escapeConsumed: [{ action: 'bash:git push --force', ts: now - 5 }], now });
expect(r.decision).toBe('block');
});
});
// enforce-supreme-gate.mjs — FIX-3: out-of-band escape журналируется (best-effort), указатель НЕ двигается
describe('runGate — FIX-3: escape best-effort журнал без продвижения указателя', () => {
const now = 1000;
const escEvent = { tool_name: 'Bash', tool_input: { command: 'git push --force' } };
const grants = [{ action: 'bash:git push --force', ts: now - 5 }];
it('escape-allow журналирует действие (escape:true) и НЕ зовёт saveStep', () => {
const journaled = []; let saved = null;
const r = runGate({ event: escEvent, frozenPlan: null, escapeGrants: grants, escapeConsumed: [], now,
journal: (e) => journaled.push(e), saveStep: (n) => { saved = n; } });
expect(r.block).toBe(false);
expect(saved).toBe(null);
expect(journaled).toHaveLength(1);
expect(journaled[0]).toMatchObject({ op: 'Bash', object: 'git push --force', escape: true });
});
it('escape-allow при сбое журнала НЕ блокирует (owner override best-effort)', () => {
const r = runGate({ event: escEvent, frozenPlan: null, escapeGrants: grants, escapeConsumed: [], now,
journal: () => { throw new Error('journal down'); }, saveStep: () => {} });
expect(r.block).toBe(false);
});
});
// R-24 (Блок B Класс 2): SEED_TOOLS экспортируется для read-only аудита покрытия дверей.
// Аддитивный export — гейт-решения неизменны; пиннинг состава seed-инструментов (анти-дрейф).
import { SEED_TOOLS } from './enforce-supreme-gate.mjs';
describe('R-24: SEED_TOOLS export (инвариантность состава)', () => {
it('экспортирован как Set с EnterPlanMode + AskUserQuestion', () => {
expect(SEED_TOOLS instanceof Set).toBe(true);
expect(SEED_TOOLS.has('EnterPlanMode')).toBe(true);
expect(SEED_TOOLS.has('AskUserQuestion')).toBe(true);
});
it('seed-инструменты не считаются seed-навыками (тулы, не Skill)', () => {
expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
});
});
// ── R-08: указатель может быть массивом индексов (дерево), не только целым ──
import { signStepState as sign3, verifyStepState as verify3, resolveStepPtr as resolve3 } from './enforce-supreme-gate.mjs';
describe('R-08: сериализованный указатель (целое ИЛИ массив) подписывается и резолвится', () => {
const SK = 'sk';
it('массив-позиция round-trip через подпись', () => {
const s = sign3('P1', [1, 1], SK);
expect(resolve3(s, 'P1', (x) => verify3(x, SK))).toEqual([1, 1]);
});
it('подмена массива-позиции ломает подпись → 0', () => {
const s = sign3('P1', [1, 1], SK);
const tampered = { ...s, ptr: [1, 0] };
expect(resolve3(tampered, 'P1', (x) => verify3(x, SK))).toBe(0);
});
it('мусор-форма ptr → 0 (SE-3 secure default)', () => {
expect(resolve3({ plan_id: 'P1', ptr: 'x' }, 'P1')).toBe(0);
expect(resolve3({ plan_id: 'P1', ptr: [1, 'a'] }, 'P1')).toBe(0);
});
it('легаси-целое по-прежнему работает (обратная совместимость)', () => {
expect(resolve3({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
});
});
// ── R-08: decide ходит по дереву; SE-1 явный advance; flat-регрессия ──
import { decide as decideTree } from './enforce-supreme-gate.mjs';
import { freezePlan as fp4, verifyFrozenPlan as vfp4 } from './plan-lock.mjs';
const K4 = 'k4';
const TREE4 = [
{ n: 1, op: 'Write', object: 'tools/a.mjs' },
{ n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'tools/x.mjs' }, { n: '2.2', op: 'Bash', object: 'npx vitest' }] },
];
const norm4 = (p) => p.toLowerCase();
describe('R-08 decide — дерево', () => {
const plan = fp4({ steps: TREE4, key: K4, nowMs: 1 });
it('указатель 0 (целое) → матч первого листа, advance:true', () => {
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
expect(r.decision).toBe('allow');
expect(r.advance).toBe(true);
});
it('указатель 1 (контейнер) → спуск, матчит лист 2.1, следующий 2.2', () => {
const r = decideTree({ toolUse: { name: 'Edit', input: { file_path: 'tools/x.mjs' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
expect(r.decision).toBe('allow');
expect(r.advanceTo).toEqual([1, 1]);
});
it('действие не лист текущей позиции → block', () => {
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
expect(r.decision).toBe('block');
});
it('контейнер сам действие не матчит (SE-2): Write на метку контейнера → block', () => {
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: '2' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
expect(r.decision).toBe('block');
});
it('битое дерево (контейнер с op) → block (validatePlanTree)', () => {
const bad = fp4({ steps: [{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }], key: K4, nowMs: 1 });
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'a' } }, frozenPlan: bad, stepPtr: 0, key: K4, normalize: norm4 });
expect(r.decision).toBe('block');
});
});
describe('R-08 flat-регрессия decide (SE-7)', () => {
const flat = fp4({ steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], key: K4, nowMs: 1 });
it('плоский план: advanceTo целое (как раньше), advance:true', () => {
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: flat, stepPtr: 0, key: K4, normalize: norm4 });
expect(r.decision).toBe('allow');
expect(r.advanceTo).toBe(1);
expect(r.advance).toBe(true);
});
});
describe('SE-2 — decideMode fail-closed whitelist по judge_mode', () => {
const planWith = (mode) => ({ judge_mode: mode, artifact_id: 'AID', steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }] });
const artWith = (mode) => ({ judge_mode: mode, artifact_id: 'AID', sections: {} });
const baseSE2 = (over) => ({ key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => p.toLowerCase(), stepPtr: 0, ...over });
const writeFoo = { name: 'Write', input: { file_path: 'tools/foo.mjs' } };
const readX = { name: 'Read', input: { file_path: 'x' } };
it('обе печати live-block → энфорсит (совпавшая правка allow)', () => {
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('live-block'), frozenArtifact: artWith('live-block') }));
expect(r.decision).toBe('allow');
expect(r.mode).toBe('implementation');
});
it('shadow-план → разговорный, мутатор block', () => {
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('shadow'), frozenArtifact: artWith('live-block') }));
expect(r.decision).toBe('block');
expect(r.mode).toBe('conversational');
});
it('shadow-печать → observe (Read) allow', () => {
const r = decideMode(baseSE2({ toolUse: readX, frozenPlan: planWith('shadow'), frozenArtifact: artWith('shadow') }));
expect(r.decision).toBe('allow');
expect(r.mode).toBe('conversational');
});
it('judge_mode null/отсутствует → разговорный, мутатор block (fail-closed)', () => {
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith(null), frozenArtifact: artWith(null) }));
expect(r.decision).toBe('block');
expect(r.mode).toBe('conversational');
});
it('опечатка режима (Shadow / live_block) → разговорный (не live-block)', () => {
expect(decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('Shadow'), frozenArtifact: artWith('live-block') })).decision).toBe('block');
expect(decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('live_block'), frozenArtifact: artWith('live-block') })).decision).toBe('block');
});
it('mode-mismatch (артефакт live-block + план shadow) → block', () => {
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('shadow'), frozenArtifact: artWith('live-block') }));
expect(r.decision).toBe('block');
});
});