5bcf229e4f
Снятие печати плана (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>
1037 lines
64 KiB
JavaScript
1037 lines
64 KiB
JavaScript
// 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);
|
||
});
|
||
});
|