Files
portal/tools/enforce-supreme-gate.test.mjs
T
Дмитрий b5b8b693f1 fix(m6): аудит-правки — G-5 egress токен, единый findOpenGrant, escape-журнал, уникальный id снимка
Аудит М6 (audit-context-building + sharp-edges + корректность; комплекс М1–М6), 4 практичных фикса (TDD):
- FIX-1: enforce-mcp-classification печатает точный FLOOR-ESCAPE токен в egress/verdict-блоке (G-5 для egress).
- FIX-2: escape-grant.findOpenGrant — единый предикат свежести для open и consume (гасит ИМЕННО открывший грант; чинит утечку one-shot при дублях/future-ts).
- FIX-3: enforce-supreme-gate.runGate — best-effort журнал escape (escape:true), указатель не двигается, сбой журнала не блокирует.
- FIX-4: enforce-snapshot — уникальный дефолтный id снимка (ts-pid-счётчик) против ms-коллизии refs/floor-snapshots.

Регрессия tools-only 2843 passed + 2 skip (+9, 0 регрессий). FIX-5 (подпись гранта) сознательно не делали (нулевая защита без ключа; protected-path уже гарантирует).
2026-06-07 19:43:05 +03:00

401 lines
24 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);
});
// 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, frozenArtifact: { sig: 'ok' }, 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, 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');
});
});
// 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);
});
});