Files
brain/tools/enforce-supreme-gate.test.mjs
T
Дмитрий 5bcf229e4f feat: plan-done и арбитраж gate3 — только терминальный грант (consent forgery B4)
Снятие печати плана (plan-done) и арбитраж gate3 (gate3-arb:accept/continue) — тело-агностичные
согласия, обходящие/снимающие стену — теперь открываются ТОЛЬКО терминальным грантом владельца
(Поза 1, HOLE-4). supreme-gate: новый параметр terminalGrants в decideMode/runGate, PLAN_FINISH_ACTION
проверяется против него (лёгкий escape остаётся на chat-грантах); main грузит loadTerminalGrants.
gate3-loop: арбитраж-гранты грузятся через loadTerminalGrants (loader-swap; resolveOwnerArbitration
агностична). Ядро стены: 138/138, gate3 44/44, полный свод 4346. Спека §B/§DEC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:03:10 +03:00

1037 lines
64 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, isQueryOnly } 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('verdict-wait сторож (строго read-only) — да; цепочка/чужой node — НЕТ', () => {
expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/verdict-wait.mjs abc judge:plan' } })).toBe(true);
expect(isObserveOnly({ name: 'Bash', input: { command: 'node verdict-wait.mjs abc 300000' } })).toBe(true);
expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/verdict-wait.mjs && rm -rf x' } })).toBe(false);
expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/other.mjs' } })).toBe(false);
});
it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => {
expect(isObserveOnly({ name: 'WebFetch' })).toBe(false);
expect(isObserveOnly({ name: 'WebSearch' })).toBe(false);
});
});
describe('isQueryOnly (B+C: смотрящие инструменты — свободны в обоих режимах)', () => {
it('ToolSearch/WebFetch/WebSearch → true (только смотрят/спрашивают)', () => {
for (const n of ['ToolSearch', 'WebFetch', 'WebSearch']) expect(isQueryOnly({ name: n })).toBe(true);
});
it('read-only браузер (navigate/snapshot/wait_for/take_screenshot, в т.ч. mcp-префикс) → true', () => {
for (const n of ['mcp__playwright__browser_navigate', 'mcp__playwright__browser_snapshot', 'mcp__playwright__browser_wait_for', 'mcp__playwright__browser_take_screenshot'])
expect(isQueryOnly({ name: n })).toBe(true);
});
it('действующий браузер (click/type/fill) → false (это не «смотреть»)', () => {
for (const n of ['mcp__playwright__browser_click', 'mcp__playwright__browser_type', 'mcp__playwright__browser_fill_form'])
expect(isQueryOnly({ name: n })).toBe(false);
});
it('мутаторы и null → false', () => {
for (const n of ['Write', 'Edit', 'MultiEdit', 'Bash']) expect(isQueryOnly({ name: n })).toBe(false);
expect(isQueryOnly(null)).toBe(false);
});
});
describe('B+C: смотрящие инструменты пускаются в обоих режимах (wiring)', () => {
it('разговорный (нет плана): ToolSearch/WebFetch/браузер-смотр → allow', () => {
for (const n of ['ToolSearch', 'WebFetch', 'WebSearch', 'mcp__playwright__browser_navigate'])
expect(decideMode({ toolUse: { name: n, input: {} }, frozenPlan: null, frozenArtifact: null, stepPtr: 0, key: 'k' }).decision).toBe('allow');
});
it('под планом: смотрящий (WebFetch) → allow без сдвига указателя', () => {
const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] };
const r = decide({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() });
expect(r.decision).toBe('allow');
expect(r.advance).not.toBe(true);
});
it('shadow-режим (не live-block печать): смотрящий тоже allow', () => {
const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }], judge_mode: 'shadow' };
const art = { artifact_id: null, judge_mode: 'shadow' };
const r = decideMode({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: art, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() });
expect(r.decision).toBe('allow');
});
it('артефакт не опечатан (бэкстоп C-10): смотрящий тоже allow', () => {
const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] };
const r = decideMode({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => false, normalize: (p) => String(p).toLowerCase() });
expect(r.decision).toBe('allow');
});
});
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);
});
});
// B+C ч.2 (точка 3 handoff, спека §3.2): указатель на шаге-сеансе op:'session'.
describe('decide() — указатель на сеансе осмотра (op:session)', () => {
const SPLAN = {
plan_id: 's', frozen_at: 1, sig: 'ok',
steps: [{ n: 1, op: 'session', goal: 'осмотр логина',
tools: ['mcp__playwright__browser_click', 'browser_type'],
produces: ['docs/observer/notes.md', 'docs/observer/report.md'] }],
};
const sctx = (over) => ctx({ frozenPlan: SPLAN, frozenArtifact: null, stepPtr: 0, ...over });
it('действующий инструмент сеанса (browser_click из набора) → allow без сдвига', () => {
const r = decide(sctx({ toolUse: { name: 'mcp__playwright__browser_click', input: {} } }));
expect(r.decision).toBe('allow');
expect(r.advance).not.toBe(true);
expect(r.reason).toMatch(/сеанс/i);
});
it('query-only под сеансом (WebFetch) → allow без сдвига', () => {
const r = decide(sctx({ toolUse: { name: 'WebFetch', input: {} } }));
expect(r.decision).toBe('allow');
expect(r.advance).not.toBe(true);
});
it('инструмент НЕ из набора и не query-only → block (default-deny держит)', () => {
const r = decide(sctx({ toolUse: { name: 'mcp__other__do_thing', input: {} } }));
expect(r.decision).toBe('block');
});
it('промежуточный produces (notes.md, не последний) → allow без сдвига', () => {
const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'docs/observer/notes.md' } } }));
expect(r.decision).toBe('allow');
expect(r.advance).not.toBe(true);
});
it('запись ПОСЛЕДНЕГО produces (report.md) → allow со сдвигом (сеанс закрыт)', () => {
const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'docs/observer/report.md' } } }));
expect(r.decision).toBe('allow');
expect(r.advance).toBe(true);
});
it('Write постороннего файла под сеансом → block (не produces, не в плане)', () => {
const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } } }));
expect(r.decision).toBe('block');
});
it('пол применяется к сеансу: промежуточный produces в секрет (.env) → block (§3.3 defense-in-depth)', () => {
const planSecret = { ...SPLAN, steps: [{ ...SPLAN.steps[0], produces: ['app/.env', 'docs/observer/report.md'] }] };
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'app/.env' } }, frozenPlan: planSecret, frozenArtifact: null, stepPtr: 0 }));
expect(r.decision).toBe('block');
expect(r.reason).toMatch(/пол|floor|escape/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('A: авторское сырьё-чтение (Read файла кода ВНЕ шага) → allow (ДР-1 снят, observe-only)', () => {
const r = decide(base({ name: 'Read', input: { file_path: 'app/Services/LeadRouter.php' } }));
expect(r.decision).toBe('allow');
});
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('A: Grep сырья под планом → allow (ДР-1 снят); Glob тоже allow', () => {
expect(decide(base({ name: 'Grep', input: { path: 'app/Services/x.php' } })).decision).toBe('allow');
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: [], terminalGrants: finGrant(now), escapeConsumed: [], now });
expect(r.decision).toBe('allow');
expect(r.mode).toBe('conversational');
expect(r.finishPlan).toBe(true);
});
it('decideMode: plan-done в escapeGrants (chat) НЕ завершает; только terminalGrants (Поза 1 B4)', () => {
const now = 1000;
const base = { toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeConsumed: [], now };
expect(decideModeFin({ ...base, escapeGrants: finGrant(now), terminalGrants: [] }).finishPlan).toBeUndefined();
expect(decideModeFin({ ...base, escapeGrants: [], terminalGrants: finGrant(now) }).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: [], terminalGrants: 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);
});
// §3.4 (десинк fix): на ПОСЛЕДНЕМ шаге печать НЕ снимается синхронно в этом же вызове —
// иначе со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидит «нет плана» и ложно заблокирует
// код-пуш. Печать снимается ЛЕНИВО на следующем действии (указатель за концом). Указатель сдвигается.
it('runGate: последний шаг выполнен → removeFrozenPlan НЕ вызван синхронно (ленивое снятие, §3.4)', () => {
let removed = 0; const saved = [];
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: (p) => saved.push(p), removeFrozenPlan: () => { removed++; },
});
expect(r.block).toBe(false);
expect(removed).toBe(0); // НЕ снято синхронно — план жив для criterion-gate
expect(saved).toContain(1); // указатель всё равно сдвинут за конец (advanceTo=1)
});
it('runGate: ленивое снятие — указатель уже за концом → removeFrozenPlan + разговорный (§3.4)', () => {
let removed = 0;
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/other.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 1, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; },
});
expect(removed).toBe(1); // план исчерпан на прошлом действии → снят лениво сейчас
expect(r.block).toBe(true); // действие вне плана → разговорный default-deny (мутатор)
expect(r.message).toMatch(/разговорн|нет замороженного плана/i);
});
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);
});
it('runGate: последний шаг → writeLoopOpen вызван (метка петли)', () => {
let opened = 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: () => {}, writeLoopOpen: () => { opened++; },
});
expect(r.block).toBe(false);
expect(opened).toBe(1);
});
it('runGate: НЕ последний шаг → writeLoopOpen НЕ вызван', () => {
let opened = 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: () => {}, writeLoopOpen: () => { opened++; },
});
expect(opened).toBe(0);
});
});
// ── F-J: двухтактный сдвиг указателя (предварительная пометка + подтверждение) ──
import { resolveTentative, computeReconcile } from './enforce-supreme-gate.mjs';
describe('F-J helpers: signState tentative round-trip', () => {
const key = 'k-fj';
it('подписывает и проверяет состояние с tentative', () => {
const s = signStepState('plan-A', 2, key, { toPtr: 3 });
expect(s.tentative).toEqual({ toPtr: 3 });
expect(verifyStepState(s, key)).toBe(true);
});
it('без tentative — формат и подпись как прежде (обратная совместимость)', () => {
const s = signStepState('plan-A', 2, key);
expect('tentative' in s).toBe(false);
expect(verifyStepState(s, key)).toBe(true);
expect(s).toEqual(signStepState('plan-A', 2, key));
});
it('подделка tentative ломает подпись', () => {
const s = signStepState('plan-A', 2, key, { toPtr: 3 });
expect(verifyStepState({ ...s, tentative: { toPtr: 9 } }, key)).toBe(false);
});
});
describe('F-J helpers: resolveTentative', () => {
const key = 'k-fj';
const v = (x) => verifyStepState(x, key);
it('валидная пометка своего плана → toPtr', () => {
expect(resolveTentative(signStepState('plan-A', 2, key, { toPtr: 3 }), 'plan-A', v)).toBe(3);
});
it('чужой план → null', () => {
expect(resolveTentative(signStepState('plan-A', 2, key, { toPtr: 3 }), 'plan-B', v)).toBe(null);
});
it('нет пометки → null', () => {
expect(resolveTentative(signStepState('plan-A', 2, key), 'plan-A', v)).toBe(null);
});
it('битая подпись → null', () => {
const s = signStepState('plan-A', 2, key, { toPtr: 3 });
expect(resolveTentative({ ...s, tentative: { toPtr: 9 } }, 'plan-A', v)).toBe(null);
});
});
describe('F-J helpers: computeReconcile', () => {
const norm = (p) => p.toLowerCase();
const plan = { steps: [
{ n: 1, op: 'Write', object: '/tmp/a.txt' },
{ n: 2, op: 'Write', object: '/tmp/b.txt' },
{ n: 3, op: 'Write', object: '/tmp/c.txt' },
] };
it('нет пометки → none, effPtr=committed', () => {
expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/a.txt' }, committedPtr: 0, tentativeToPtr: null, normalize: norm }))
.toEqual({ state: 'none', effPtr: 0 });
});
it('действие = шаг toPtr → commit', () => {
expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/b.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm }))
.toEqual({ state: 'commit', effPtr: 1 });
});
it('повтор шага ptr → discard', () => {
expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/a.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm }))
.toEqual({ state: 'discard', effPtr: 0 });
});
it('ни то ни другое (observe-op) → hold', () => {
expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Read', object: '/tmp/b.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm }))
.toEqual({ state: 'hold', effPtr: 0 });
});
});
describe('F-J runGate deferral', () => {
const key = 'k-fj';
const lc = (p) => p.toLowerCase();
const plan = { plan_id: 'P', judge_mode: 'live-block', steps: [
{ n: 1, op: 'Write', object: '/tmp/a.txt' },
{ n: 2, op: 'Write', object: '/tmp/b.txt' },
] };
const art = { judge_mode: 'live-block' };
const mk = () => { const calls = []; return { calls, saveStep: (p, t) => calls.push([p, t]), journal: () => true, removeFrozenPlan: () => calls.push(['remove']) }; };
const ev = (name, input) => ({ tool_name: name, tool_input: input });
const base = (io) => ({ frozenPlan: plan, frozenArtifact: art, key, verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: lc, ...io });
it('не последний шаг: saveStep с пометкой, не голым advanceTo', () => {
const io = mk();
runGate({ event: ev('Write', { file_path: '/tmp/a.txt' }), stepPtr: 0, tentativeToPtr: null, ...base(io) });
expect(io.calls).toContainEqual([0, { toPtr: 1 }]);
});
it('пометка открыта + действие следующего шага → commit saveStep(toPtr,null)', () => {
const io = mk();
runGate({ event: ev('Write', { file_path: '/tmp/b.txt' }), stepPtr: 0, tentativeToPtr: 1, ...base(io) });
expect(io.calls.some((c) => c[0] === 1 && c[1] === null)).toBe(true);
});
it('пометка открыта + повтор шага → discard saveStep(ptr,null), не уходит вперёд', () => {
const io = mk();
const r = runGate({ event: ev('Write', { file_path: '/tmp/a.txt' }), stepPtr: 0, tentativeToPtr: 1, ...base(io) });
expect(io.calls.some((c) => c[0] === 0 && c[1] === null)).toBe(true);
expect(r.block).toBe(false);
});
});