Files
brain/tools/enforce-supreme-gate.test.mjs
T

805 lines
48 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' });
});
// Δ7+ (floor-desync fix 2026-06-14): шаг плана, который заблокировал бы ПОЛ (content-block
// правило 8 — node -e/curl/eval — НЕ только classify-destructive floor-набор), стена обязана
// блокировать БЕЗ сдвига указателя. Иначе supreme-gate сдвигал указатель, пол рубил
// исполнение → шаг терялся (desync, потеря safety-шага). Escape тут пуст (его путь — decideMode).
it('Δ7+: floor-блокируемый шаг (node -e content-block) → block БЕЗ сдвига указателя', () => {
const cmd = 'node -e "1"';
const planNode = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Bash', object: cmd, intent: 'i' }], sig: 'ok' };
const r = decide(ctx({ toolUse: { name: 'Bash', input: { command: cmd } }, frozenPlan: planNode, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/пол|floor|Δ7/i);
expect(r.advance).not.toBe(true);
expect(r.advanceTo).toBeUndefined();
});
// Δ7+ регрессия: classify-destructive floor-набор (force-push) как шаг плана — по-прежнему block.
it('Δ7+: разрушительный шаг (git push --force) → block БЕЗ сдвига (поведение сохранено)', () => {
const cmd = 'git push --force';
const planF = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Bash', object: cmd, intent: 'i' }], sig: 'ok' };
const r = decide(ctx({ toolUse: { name: 'Bash', input: { command: cmd } }, frozenPlan: planF, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.advanceTo).toBeUndefined();
});
});
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');
});
});
// sub-plan E Task 4 (✅O18): рассинхрон judge_mode — ГРОМКО, не тихо
import { judgeModeMismatch } from './enforce-supreme-gate.mjs';
describe('judgeModeMismatch (✅O18)', () => {
it('одна live-block, другая нет → рассинхрон true', () => {
expect(judgeModeMismatch('live-block', 'shadow')).toBe(true);
expect(judgeModeMismatch('shadow', 'live-block')).toBe(true);
});
it('обе одинаковы → не рассинхрон', () => {
expect(judgeModeMismatch('live-block', 'live-block')).toBe(false);
expect(judgeModeMismatch('shadow', 'shadow')).toBe(false);
expect(judgeModeMismatch(undefined, null)).toBe(false);
});
});
describe('decideMode — warn при рассинхроне (✅O18)', () => {
it('plan=live-block, artifact=shadow, мутатор → conversational + warn:true', () => {
const r = decideMode({
toolUse: { name: 'Write', input: { file_path: 'x' } },
frozenPlan: { judge_mode: 'live-block' },
frozenArtifact: { judge_mode: 'shadow' },
stepPtr: 0, key: null,
verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (s) => s,
escapeGrants: [], escapeConsumed: [], now: 1000,
});
expect(r.mode).toBe('conversational');
expect(r.warn).toBe(true);
expect(r.warnReason).toMatch(/рассинхрон/);
});
it('обе shadow → conversational БЕЗ warn (намеренно off, не footgun)', () => {
const r = decideMode({
toolUse: { name: 'Write', input: { file_path: 'x' } },
frozenPlan: { judge_mode: 'shadow' },
frozenArtifact: { judge_mode: 'shadow' },
stepPtr: 0, key: null,
verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (s) => s,
escapeGrants: [], escapeConsumed: [], now: 1000,
});
expect(r.warn).toBeUndefined();
});
});
// W2 (C2, нах.F3/SE5/Д-С2-1): reading-gate ДР-1 в стене — импорт внизу (ESM hoisting)
import { buildPlanAuthorizesPath, runGate } from './enforce-supreme-gate.mjs';
describe('W4 — runGate прокидывает warn O18 в message (owner видит)', () => {
it('judge_mode рассинхрон (план live-block, артефакт shadow) → message несёт warnReason', () => {
const r = runGate({
event: { tool_name: 'Write', tool_input: { file_path: 'x' } },
frozenPlan: { judge_mode: 'live-block' },
frozenArtifact: { judge_mode: 'shadow' },
stepPtr: 0, key: null,
verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (s) => s,
journal: () => true, saveStep: () => {},
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/рассинхрон/);
});
it('без рассинхрона message без warn-хвоста', () => {
const r = runGate({
event: { tool_name: 'Write', tool_input: { file_path: 'x' } },
frozenPlan: { judge_mode: 'shadow' },
frozenArtifact: { judge_mode: 'shadow' },
stepPtr: 0, key: null,
verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (s) => s,
journal: () => true, saveStep: () => {},
});
expect(r.message).not.toMatch(/рассинхрон/);
});
});
describe('W2 — reading-gate ДР-1 в decide (impl-режим)', () => {
const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] };
const base = (toolUse) => ({ toolUse, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() });
it('авторское сырьё-чтение (Read файла кода ВНЕ шага плана) → блок ДР-1', () => {
const r = decide(base({ name: 'Read', input: { file_path: 'app/Services/LeadRouter.php' } }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/ДР-1/);
});
it('Read файла из шага плана (harness-обязательное, op-агностично: шаг Edit) → allow', () => {
const r = decide(base({ name: 'Read', input: { file_path: 'tools/foo.mjs' } }));
expect(r.decision).toBe('allow');
});
it('чтение граф-карты → allow (вид-1 свободен)', () => {
const r = decide(base({ name: 'Read', input: { file_path: '.claude/worktrees/graphify-spike/graphify-out/graph.json' } }));
expect(r.decision).toBe('allow');
});
it('Grep сырья вне плана → блок; Glob не гейтится (W2 scope: Read/Grep)', () => {
expect(decide(base({ name: 'Grep', input: { path: 'app/Services/x.php' } })).decision).toBe('block');
expect(decide(base({ name: 'Glob', input: { pattern: '**/*.php' } })).decision).toBe('allow');
});
it('без замороженного плана decide гейт ДР-1 не зовёт (разговорный — не его зона)', () => {
const r = decide({ toolUse: { name: 'Read', input: { file_path: 'app/x.php' } }, frozenPlan: null, stepPtr: 0, key: 'k' });
expect(r.decision).toBe('allow'); // observe-only, как раньше (:314)
});
});
describe('buildPlanAuthorizesPath (W2 продюсер, SE5/Д-С2-1)', () => {
const plan = { steps: [{ n: 1, op: 'Edit', object: 'Tools/Foo.mjs' }, { n: 2, op: 'Bash', object: 'git status' }] };
const low = (p) => String(p).toLowerCase();
it('лист с совпадающим объектом (op-агностично, через normalize) → true', () => {
expect(buildPlanAuthorizesPath(plan, { normalize: low })('tools/foo.mjs')).toBe(true);
});
it('чужой путь → false; Bash-шаг (объект-команда) путь НЕ авторизует', () => {
const auth = buildPlanAuthorizesPath(plan, { normalize: low });
expect(auth('app/other.php')).toBe(false);
expect(auth('git status')).toBe(false);
});
it('битый план / normalize-бросок → false (fail-safe, не throw)', () => {
expect(buildPlanAuthorizesPath(null, { normalize: low })('x')).toBe(false);
const boom = buildPlanAuthorizesPath(plan, { normalize: () => { throw new Error('x'); } });
expect(boom('tools/foo.mjs')).toBe(false);
});
it('F-C2-2: scope = текущий + ПРОЙДЕННЫЕ шаги — будущий шаг НЕ авторизован (нет фронт-рана)', () => {
const plan2 = { steps: [{ n: 1, op: 'Edit', object: 'tools/a.mjs' }, { n: 2, op: 'Edit', object: 'tools/b.mjs' }] };
const at0 = buildPlanAuthorizesPath(plan2, { stepPtr: 0, normalize: low });
expect(at0('tools/a.mjs')).toBe(true);
expect(at0('tools/b.mjs')).toBe(false); // будущее — блок
const at1 = buildPlanAuthorizesPath(plan2, { stepPtr: 1, normalize: low });
expect(at1('tools/b.mjs')).toBe(true);
expect(at1('tools/a.mjs')).toBe(true); // пройденное перечитывается
});
it('F-C2-2: указатель не резолвится (за концом плана) → ничего не авторизовано (fail-closed)', () => {
const plan1 = { steps: [{ n: 1, op: 'Edit', object: 'tools/a.mjs' }] };
expect(buildPlanAuthorizesPath(plan1, { stepPtr: 99, normalize: low })('tools/a.mjs')).toBe(false);
});
});
import { decideMode as decideModeFin, runGate as runGateFin, PLAN_FINISH_ACTION } from './enforce-supreme-gate.mjs';
describe('Фаза 5 Task 5.2 — досрочное завершение владельцем (finish-грант «plan-done», Вариант А)', () => {
const KFIN = 'k';
const verifyFin = (p) => p && p.sig !== 'BAD';
const lowFin = (p) => p.toLowerCase();
const PLANF = { plan_id: 'pf', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Edit', object: 'tools/b.mjs' }], sig: 'ok', judge_mode: 'live-block' };
const ART = { sig: 'ok', judge_mode: 'live-block' };
const finGrant = (now) => [{ action: PLAN_FINISH_ACTION, ts: now }];
it('PLAN_FINISH_ACTION — зарезервированная метка (не совпадает с реальными действиями)', () => {
expect(typeof PLAN_FINISH_ACTION).toBe('string');
expect(PLAN_FINISH_ACTION).not.toMatch(/^(write|bash|skill|mcp|powershell):/);
});
it('decideMode: открыт finish-грант + есть план → allow, conversational, finishPlan:true (даже на середине)', () => {
const now = 1000;
const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: finGrant(now), escapeConsumed: [], now });
expect(r.decision).toBe('allow');
expect(r.mode).toBe('conversational');
expect(r.finishPlan).toBe(true);
});
it('decideMode: нет finish-гранта → обычный план-режим (finishPlan не выставлен)', () => {
const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: [], escapeConsumed: [], now: 1000 });
expect(r.finishPlan).toBeUndefined();
});
it('runGate: finishPlan → removeFrozenPlan вызван + allow (печать снята досрочно)', () => {
let removed = 0; const now = 1000;
const r = runGateFin({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN,
verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
escapeGrants: finGrant(now), escapeConsumed: [], now,
});
expect(r.block).toBe(false);
expect(removed).toBe(1);
});
});
import { decide as decideCE, runGate as runGateCE } from './enforce-supreme-gate.mjs';
describe('Фаза 5 — чистое завершение плана (стена сама снимает печать)', () => {
const KCE = 'k-ce';
const verifyCE = (p) => p && p.sig !== 'BAD';
const lowCE = (p) => p.toLowerCase();
const PLAN1 = { plan_id: 'p1', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }], sig: 'ok' };
const PLAN2 = { plan_id: 'p2', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/a.mjs' }, { n: 2, op: 'Edit', object: 'tools/b.mjs' }], sig: 'ok' };
const dctx = (over) => ({ key: KCE, frozenArtifact: { sig: 'ok' }, verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, ...over });
it('decide: последний шаг плана → allow + planComplete:true', () => {
const r = decideCE(dctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLAN1, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.planComplete).toBe(true);
});
it('decide: НЕ последний шаг (впереди есть) → planComplete:false', () => {
const r = decideCE(dctx({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: PLAN2, stepPtr: 0 }));
expect(r.decision).toBe('allow');
expect(r.planComplete).toBe(false);
});
it('runGate: последний шаг выполнен → removeFrozenPlan вызван (печать снята → разговорный)', () => {
let removed = 0;
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
});
expect(r.block).toBe(false);
expect(removed).toBe(1);
});
it('runGate: НЕ последний шаг → removeFrozenPlan НЕ вызван (печать держится)', () => {
let removed = 0;
runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/a.mjs' } },
frozenPlan: { ...PLAN2, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
});
expect(removed).toBe(0);
});
it('runGate: снятие печати best-effort — бросок removeFrozenPlan НЕ ломает allow', () => {
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { throw new Error('unlink fail'); },
});
expect(r.block).toBe(false);
});
});