0289e93c0e
М7 Фаза 8 — последние 2 хвоста до флипа. Inline TDD; спека+план одобрены; 2 адверсариальных прохода. Гейт-1 (печать артефакта в рантайме): - extractGate1Product (enforce-judge-gate.mjs): детект записи specs/*.md (Write-only) → судить линзами gate1 (движок уже поддерживает). runJudgeGate: спека→gate1, иначе план→gate2. - judged_hash от СЫРОГО content (SD-1, разбор #1 High): trimmed source_sha не сошёлся бы с печатью sealOnWiredGo → артефакт НИКОГДА не печатался бы. Один канон судья→печать. - extractGate2Product не тронут (Гейт-2 = планы, регрессия зелёная). SE-2 (стена не чтит shadow): - decideMode (enforce-supreme-gate.mjs): fail-closed whitelist — энфорсмент ТОЛЬКО при judge_mode==='live-block' на ОБЕИХ печатях; shadow/null/опечатка → разговорный (мутаторы block). Разбор #1 (VA-a/SE-a): убрана единственная fail-OPEN сверка; опечатка режима безопасна. - мигрированы 3 enforce-теста стены на judge_mode:'live-block'. Тесты: gate1 10, SE-2 6; регрессия tools-only 3443 passed / 2 skip / 0 регрессий. Гейт-1 тесты — отдельный файл enforce-judge-gate-gate1.test.mjs (real-test-verifier блокирует import-only Edit существующего теста). Печать в рантайме до флипа НЕ производится.
567 lines
34 KiB
JavaScript
567 lines
34 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 } from './enforce-supreme-gate.mjs';
|
||
import { resolveStepPtr } from './enforce-supreme-gate.mjs';
|
||
import { resolveSessionId } from './enforce-supreme-gate.mjs';
|
||
import { signStepState, verifyStepState } from './enforce-supreme-gate.mjs';
|
||
import { stepStatePath } from './enforce-supreme-gate.mjs';
|
||
|
||
// N3-shared (2026-06-07 аудит M1-M4): путь файла указателя шага строится из sessionId
|
||
// (resolveSessionId(event), недоверенный источник) — тот же guard формы, что action-journal.
|
||
describe('N3: stepStatePath path-injection guard', () => {
|
||
it('нормальный sessionId → детерминированный путь', () => {
|
||
expect(stepStatePath('/rt', 'S1')).toBe('/rt/plan-step-S1');
|
||
expect(stepStatePath('/rt/', 'unknown')).toBe('/rt/plan-step-unknown');
|
||
});
|
||
it('traversal/слэш/точка/обратный слэш → throw', () => {
|
||
expect(() => stepStatePath('/rt', '../evil')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a/b')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a.b')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a\\b')).toThrow();
|
||
});
|
||
});
|
||
|
||
import { panicEscapeDecision } from './enforce-supreme-gate.mjs';
|
||
import { canonicalAction } from './escape-grant.mjs';
|
||
|
||
describe('supreme-gate panicEscapeDecision (M7 Фаза 2, правило 7б — сетап main бросил)', () => {
|
||
const now = 1_000_000;
|
||
const ev = { tool_name: 'Bash', tool_input: { command: 'git push --force' } };
|
||
it('сетап бросил + матч escape-грант → block:false', () => {
|
||
const action = canonicalAction('Bash', { command: 'git push --force' });
|
||
expect(panicEscapeDecision(ev, [{ action, ts: now - 1000 }], [], now).block).toBe(false);
|
||
});
|
||
it('сетап бросил БЕЗ escape → block:true (fail-CLOSED)', () => {
|
||
expect(panicEscapeDecision(ev, [], [], now).block).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('supreme-gate decideMode escape ДО бросающего verify (M7 Фаза 2, правило 7а-ordering)', () => {
|
||
const now = 1_000_000;
|
||
it('матч escape-грант + БРОСАЮЩИЙ verify* → allow mode:escape (verify не вызван)', () => {
|
||
const toolUse = { name: 'Bash', input: { command: 'rm -rf /' } };
|
||
const action = canonicalAction('Bash', { command: 'rm -rf /' });
|
||
const r = decideMode({
|
||
toolUse, frozenPlan: { steps: [], plan_id: 'p' }, frozenArtifact: { artifact_id: 'a' },
|
||
key: 'k', escapeGrants: [{ action, ts: now - 1000 }], escapeConsumed: [], now,
|
||
verifyImpl: () => { throw new Error('verify boom'); },
|
||
verifyArtifactImpl: () => { throw new Error('artifact boom'); },
|
||
});
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('escape');
|
||
});
|
||
});
|
||
|
||
describe('SEED_SKILLS — реактивные дисциплинарные навыки seed-allowed (M7 Фаза 3, SE-K)', () => {
|
||
const sk = (skill) => ({ name: 'Skill', input: { skill } });
|
||
for (const s of ['systematic-debugging', 'test-driven-development', 'requesting-code-review', 'verification-before-completion']) {
|
||
it(`isSeed для ${s} → true (реактивный навык не рубится «вне плана»)`, () => {
|
||
expect(isSeed(sk(`superpowers:${s}`))).toBe(true);
|
||
});
|
||
}
|
||
it('не-seed навык (coder) → false (регресс — не все навыки seed)', () => {
|
||
expect(isSeed(sk('coder'))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('R-19: указатель шага подписан (подмена ptr без ключа → сброс)', () => {
|
||
const SK = 'step-key';
|
||
it('валидно подписанный → ptr; подменённый ptr со старой подписью → 0', () => {
|
||
const signed = signStepState('P1', 3, SK);
|
||
expect(resolveStepPtr(signed, 'P1', (s) => verifyStepState(s, SK))).toBe(3);
|
||
const tampered = { ...signed, ptr: 9 };
|
||
expect(resolveStepPtr(tampered, 'P1', (s) => verifyStepState(s, SK))).toBe(0);
|
||
});
|
||
it('без verify-колбэка — обратная совместимость R-27 сохранена', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe('resolveSessionId — берёт session_id из события, не из env (R-28)', () => {
|
||
it('из event.session_id (канон Claude Code stdin)', () => {
|
||
expect(resolveSessionId({ session_id: 'S-42' }, {})).toBe('S-42');
|
||
});
|
||
it('фолбэк на env, затем unknown', () => {
|
||
expect(resolveSessionId({}, { CLAUDE_SESSION_ID: 'E-1' })).toBe('E-1');
|
||
expect(resolveSessionId(null, {})).toBe('unknown');
|
||
});
|
||
});
|
||
|
||
describe('resolveStepPtr — указатель шага привязан к plan_id (R-27)', () => {
|
||
it('тот же план → возвращает сохранённый ptr', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
|
||
});
|
||
it('другой план → сброс в 0 (перепечать не отматывает чужой указатель)', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P2')).toBe(0);
|
||
});
|
||
it('легаси (голое число) / null / без plan_id → 0', () => {
|
||
expect(resolveStepPtr(5, 'P1')).toBe(0);
|
||
expect(resolveStepPtr(null, 'P1')).toBe(0);
|
||
expect(resolveStepPtr({ ptr: 9 }, 'P1')).toBe(0);
|
||
});
|
||
it('нет текущего плана → 0', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, undefined)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('isSeed (D12/D13 bootstrap exemption)', () => {
|
||
it('EnterPlanMode and AskUserQuestion are seeds', () => {
|
||
expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
|
||
expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
|
||
});
|
||
it('Skill(writing-plans) / brainstorming / discovery-interview are seeds', () => {
|
||
for (const s of SEED_SKILLS) {
|
||
expect(isSeed({ name: 'Skill', input: { skill: s } })).toBe(true);
|
||
expect(isSeed({ name: 'Skill', input: { skill: 'superpowers:' + s } })).toBe(true);
|
||
}
|
||
});
|
||
it('a non-seed Skill is NOT a seed', () => {
|
||
expect(isSeed({ name: 'Skill', input: { skill: 'coder' } })).toBe(false);
|
||
});
|
||
it('mutating tools are not seeds', () => {
|
||
expect(isSeed({ name: 'Write', input: { file_path: 'x' } })).toBe(false);
|
||
expect(isSeed({ name: 'Bash', input: { command: 'rm x' } })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('actionOf — поля объекта (F1, выровнено с B4)', () => {
|
||
it('берёт объект из доп. полей MCP-писателей (filename/uri/destination)', () => {
|
||
expect(actionOf({ name: 'mcp__fs__write', input: { filename: 'tools/foo.mjs' } }).object).toBe('tools/foo.mjs');
|
||
expect(actionOf({ name: 'mcp__x', input: { uri: 'a/b.txt' } }).object).toBe('a/b.txt');
|
||
expect(actionOf({ name: 'mcp__y', input: { destination: 'd/e.json' } }).object).toBe('d/e.json');
|
||
});
|
||
});
|
||
|
||
describe('decide — самодостаточная проверка печати артефакта (F2, защита-в-глубину)', () => {
|
||
it('битая печать артефакта при верном id → block (даже без обёртки decideMode)', () => {
|
||
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
|
||
const plan = { steps: [step], artifact_id: 'AID' };
|
||
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
|
||
const verifyImpl = (obj) => obj === plan; // план валиден, артефакт — НЕТ (битая печать)
|
||
const r = decide({
|
||
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
|
||
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
|
||
verifyImpl, normalize: (p) => p,
|
||
});
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/печать артефакта|seal/i);
|
||
});
|
||
it('R-31 split: артефакт проверяется ОТДЕЛЬНЫМ verifyArtifactImpl (не impl плана)', () => {
|
||
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
|
||
const plan = { steps: [step], artifact_id: 'AID' };
|
||
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
|
||
const r = decide({
|
||
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
|
||
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
|
||
verifyImpl: (o) => o === plan, verifyArtifactImpl: () => true, normalize: (p) => p,
|
||
});
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
});
|
||
|
||
const KEY = 'supreme-key';
|
||
const PLAN = {
|
||
plan_id: 'x', frozen_at: 1,
|
||
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }],
|
||
sig: 'unused-in-stub',
|
||
};
|
||
// stub verify: считаем план валидным если sig !== 'BAD'
|
||
const verifyStub = (p) => p && p.sig !== 'BAD';
|
||
const ctx = (over) => ({ key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(), ...over });
|
||
|
||
describe('supreme-gate decide()', () => {
|
||
it('seed passes even with no frozen plan', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.reason).toMatch(/seed/i);
|
||
});
|
||
it('default-deny when there is no frozen plan (non-seed)', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/нет замороженного плана|no frozen plan/i);
|
||
});
|
||
it('default-deny when frozen plan signature is invalid', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: { ...PLAN, sig: 'BAD' }, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/печать|seal|signature/i);
|
||
});
|
||
it('allow when action matches the current plan step', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advanceTo).toBe(1);
|
||
});
|
||
it('block when action does NOT match the current step (not in plan)', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/не в плане|not in plan/i);
|
||
});
|
||
it('actionOf maps a tool event to {op, object}', () => {
|
||
expect(actionOf({ name: 'Write', input: { file_path: 'a.mjs' } })).toEqual({ op: 'Write', object: 'a.mjs' });
|
||
expect(actionOf({ name: 'Bash', input: { command: 'rm x' } })).toEqual({ op: 'Bash', object: 'rm x' });
|
||
expect(actionOf({ name: 'Skill', input: { skill: 'coder' } })).toEqual({ op: 'Skill', object: 'coder' });
|
||
});
|
||
});
|
||
|
||
describe('runGate (pure orchestration)', () => {
|
||
it('allow advances pointer and journals the action', () => {
|
||
const journaled = [];
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||
frozenPlan: { ...PLAN, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: (entry) => journaled.push(entry), saveStep: (n) => (journaled.savedStep = n),
|
||
});
|
||
expect(r.block).toBe(false);
|
||
expect(journaled).toHaveLength(1);
|
||
expect(journaled.savedStep).toBe(1);
|
||
});
|
||
it('block does NOT journal or advance', () => {
|
||
const journaled = [];
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
|
||
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: (e) => journaled.push(e), saveStep: () => {},
|
||
});
|
||
expect(r.block).toBe(true);
|
||
expect(journaled).toHaveLength(0);
|
||
});
|
||
|
||
// 8.1 (Δ3) — enforce-supreme-gate: пред-запись НАМЕРЕНИЯ в журнал ДО allow. Нет пред-записи
|
||
// (журнал вернул false ИЛИ бросил) → стена НЕ разрешает (block), шаг не двигается.
|
||
it('Δ3 (8.1): journal вернул false → block, шаг НЕ двигается', () => {
|
||
let saved = null;
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||
frozenPlan: { ...PLAN, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: () => false, saveStep: (n) => { saved = n; },
|
||
});
|
||
expect(r.block).toBe(true);
|
||
expect(r.message).toMatch(/пред-запис|нет записи/i);
|
||
expect(saved).toBe(null);
|
||
});
|
||
it('Δ3 (8.1): journal бросил исключение → block (fail-closed на пред-записи)', () => {
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: () => { throw new Error('io'); }, saveStep: () => {},
|
||
});
|
||
expect(r.block).toBe(true);
|
||
});
|
||
});
|
||
|
||
const verifyStub2 = (p) => p && p.sig !== 'BAD';
|
||
const baseMode = (over) => ({ key: 'k', verifyImpl: verifyStub2, verifyArtifactImpl: verifyStub2, normalize: (p) => p.toLowerCase(), ...over });
|
||
|
||
describe('decideMode (C-7: разговорный vs реализационный)', () => {
|
||
it('разговорная фаза (нет плана-стройки): семя проходит', () => {
|
||
const r = decideMode(baseMode({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, frozenArtifact: null }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('разговорная фаза: инструмент реализации блокируется (только думать/спрашивать)', () => {
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: null, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.mode).toBe('conversational');
|
||
expect(r.reason).toMatch(/разговорн|только думать|спрашивать/i);
|
||
});
|
||
it('бэкстоп: реализационный план без замороженного артефакта → блок', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/артефакт|разговор/i);
|
||
});
|
||
it('есть артефакт+план+совпавший шаг → стройка (передаём в decide)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, judge_mode: 'live-block', steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
|
||
const ART = { artifact_id: 'a', judge_mode: 'live-block', sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLANL, frozenArtifact: ART, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('implementation');
|
||
});
|
||
});
|
||
|
||
// stub classifyBash: readonly только для «безопасных» команд, редирект/писатель → не readonly (условие А)
|
||
const classifyBashStub = (cmd) => {
|
||
if (/[>|]|git\s+(config|gc|push|commit)|tee\b|\$\(/.test(cmd)) return { result: 'block', reason: 'mutating' };
|
||
if (/^(git\s+(status|log|diff|show)|grep|cat|ls)\b/.test(cmd)) return { result: 'allow', reason: 'readonly' };
|
||
return { result: 'block', reason: 'unknown' };
|
||
};
|
||
|
||
describe('isObserveOnly (зелёный проход = нет долговременного/исходящего эффекта, finding 9)', () => {
|
||
it('локальные смотрящие — да', () => {
|
||
for (const n of ['Read', 'Grep', 'Glob']) expect(isObserveOnly({ name: n })).toBe(true);
|
||
});
|
||
it('TodoWrite (эфемерный черновик) — да', () => {
|
||
expect(isObserveOnly({ name: 'TodoWrite', input: {} })).toBe(true);
|
||
});
|
||
it('read-only Bash по эффекту — да; редирект/писатель — НЕТ (условие А)', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } }, { classifyBash: classifyBashStub })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git log > evil.sh' } }, { classifyBash: classifyBashStub })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git config x y' } }, { classifyBash: classifyBashStub })).toBe(false);
|
||
});
|
||
it('мутирующие — НЕТ', () => {
|
||
for (const n of ['Write', 'Edit', 'MultiEdit', 'Task']) expect(isObserveOnly({ name: n })).toBe(false);
|
||
});
|
||
it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => {
|
||
expect(isObserveOnly({ name: 'WebFetch' })).toBe(false);
|
||
expect(isObserveOnly({ name: 'WebSearch' })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('decide() пропускает зелёный проход без шага плана', () => {
|
||
it('Read проходит без плана', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.reason).toMatch(/observe|смотр|зелён/i);
|
||
});
|
||
});
|
||
|
||
const ARTID = 'aid-1';
|
||
const PLAN_REF = { plan_id: 'p', artifact_id: ARTID, frozen_at: 1,
|
||
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
|
||
const ART_OK = { artifact_id: ARTID, sections: { '§1': 'teal' }, sig: 'ok' };
|
||
|
||
describe('decide() закрытая дверь + привязка к версии артефакта (C-5)', () => {
|
||
it('совпавший шаг + ссылка резолвится + версия совпала → allow', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: PLAN_REF, frozenArtifact: ART_OK, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
it('ссылка шага не резолвится в артефакте → block', () => {
|
||
const planBadRef = { ...PLAN_REF, steps: [{ ...PLAN_REF.steps[0], ref: '§9' }] };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: planBadRef, frozenArtifact: ART_OK, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/не резолвится|закрыт|C-5/i);
|
||
});
|
||
it('версия артефакта не та, под которую печатали план → block', () => {
|
||
const otherArt = { ...ART_OK, artifact_id: 'aid-2' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: PLAN_REF, frozenArtifact: otherArt, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/версия|artifact|пере-печать/i);
|
||
});
|
||
});
|
||
|
||
// F-A (аудит 2026-06-07): зелёный проход Bash через НАСТОЯЩИЙ classifyBashCommand.
|
||
// reason 'whitelisted reading command(s)' схлопывается, если в цепочке есть хоть
|
||
// один читатель — тогда whitelisted-мутатор за `cat x &&` проскакивал зелёным
|
||
// проходом (composer pint → переписать исходники, migrate:fresh → дроп БД,
|
||
// node <script> → произвольный JS). Эти тесты используют дефолтный classifyBash.
|
||
describe('isObserveOnly — F-A: цепочка читатель+мутатор НЕ зелёный проход (real classify)', () => {
|
||
it('читатель + whitelisted-мутатор → НЕ observe-only', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && composer pint' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat README && php artisan migrate:fresh' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && node evil.mjs' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'composer pint && grep x README' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && pest' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && npm test' } })).toBe(false);
|
||
});
|
||
it('чистая цепочка читателей + одиночный readonly-git остаются зелёными', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && grep b a' } })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README' } })).toBe(true);
|
||
});
|
||
});
|
||
|
||
// F-B (аудит 2026-06-07): при плане без валидного артефакта observe-only не должен
|
||
// душиться (инвариант finding 9 + согласованность с decide()).
|
||
describe('decideMode — F-B: observe-only проходит даже при плане без артефакта', () => {
|
||
it('Read при плане + пропавшем артефакте → allow (не блок)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('Write при плане + пропавшем артефакте по-прежнему блокируется (бэкстоп цел)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
});
|
||
|
||
// F-C (аудит 2026-06-07): шаг с ref обязан резолвиться в опечатанном артефакте
|
||
// НЕЗАВИСИМО от того, привязан ли план к artifact_id (закрытая дверь не
|
||
// выключается планом без artifact_id).
|
||
describe('decide — F-C: ref при плане без artifact_id всё равно требует артефакт', () => {
|
||
it('шаг с ref + план без artifact_id + нет артефакта → block', () => {
|
||
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/ссылк|закрыт|артефакт|C-5/i);
|
||
});
|
||
it('шаг без ref + план без artifact_id → allow (легаси не сломан)', () => {
|
||
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
});
|
||
|
||
// enforce-supreme-gate.mjs — G-1 α: сквозной floor_escape (allow БЕЗ advanceTo, M6 Пакет 4b)
|
||
describe('supreme-gate escape (M6 G-1 α)', () => {
|
||
const now = 1000;
|
||
it('разрушительное с совпавшим floor_escape (разговорный режим) → allow без advanceTo', () => {
|
||
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
|
||
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }], escapeConsumed: [], now });
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advanceTo).toBeUndefined();
|
||
});
|
||
it('без пропуска разговорный мутатор → block (поведение М2 не изменилось)', () => {
|
||
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
|
||
frozenPlan: null, escapeGrants: [], escapeConsumed: [], now });
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
it('погашенный пропуск (one-shot) → block', () => {
|
||
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
|
||
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }],
|
||
escapeConsumed: [{ action: 'bash:git push --force', ts: now - 5 }], now });
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
});
|
||
|
||
// enforce-supreme-gate.mjs — FIX-3: out-of-band escape журналируется (best-effort), указатель НЕ двигается
|
||
describe('runGate — FIX-3: escape best-effort журнал без продвижения указателя', () => {
|
||
const now = 1000;
|
||
const escEvent = { tool_name: 'Bash', tool_input: { command: 'git push --force' } };
|
||
const grants = [{ action: 'bash:git push --force', ts: now - 5 }];
|
||
it('escape-allow журналирует действие (escape:true) и НЕ зовёт saveStep', () => {
|
||
const journaled = []; let saved = null;
|
||
const r = runGate({ event: escEvent, frozenPlan: null, escapeGrants: grants, escapeConsumed: [], now,
|
||
journal: (e) => journaled.push(e), saveStep: (n) => { saved = n; } });
|
||
expect(r.block).toBe(false);
|
||
expect(saved).toBe(null);
|
||
expect(journaled).toHaveLength(1);
|
||
expect(journaled[0]).toMatchObject({ op: 'Bash', object: 'git push --force', escape: true });
|
||
});
|
||
it('escape-allow при сбое журнала НЕ блокирует (owner override best-effort)', () => {
|
||
const r = runGate({ event: escEvent, frozenPlan: null, escapeGrants: grants, escapeConsumed: [], now,
|
||
journal: () => { throw new Error('journal down'); }, saveStep: () => {} });
|
||
expect(r.block).toBe(false);
|
||
});
|
||
});
|
||
|
||
// R-24 (Блок B Класс 2): SEED_TOOLS экспортируется для read-only аудита покрытия дверей.
|
||
// Аддитивный export — гейт-решения неизменны; пиннинг состава seed-инструментов (анти-дрейф).
|
||
import { SEED_TOOLS } from './enforce-supreme-gate.mjs';
|
||
describe('R-24: SEED_TOOLS export (инвариантность состава)', () => {
|
||
it('экспортирован как Set с EnterPlanMode + AskUserQuestion', () => {
|
||
expect(SEED_TOOLS instanceof Set).toBe(true);
|
||
expect(SEED_TOOLS.has('EnterPlanMode')).toBe(true);
|
||
expect(SEED_TOOLS.has('AskUserQuestion')).toBe(true);
|
||
});
|
||
it('seed-инструменты не считаются seed-навыками (тулы, не Skill)', () => {
|
||
expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
|
||
expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ── R-08: указатель может быть массивом индексов (дерево), не только целым ──
|
||
import { signStepState as sign3, verifyStepState as verify3, resolveStepPtr as resolve3 } from './enforce-supreme-gate.mjs';
|
||
|
||
describe('R-08: сериализованный указатель (целое ИЛИ массив) подписывается и резолвится', () => {
|
||
const SK = 'sk';
|
||
it('массив-позиция round-trip через подпись', () => {
|
||
const s = sign3('P1', [1, 1], SK);
|
||
expect(resolve3(s, 'P1', (x) => verify3(x, SK))).toEqual([1, 1]);
|
||
});
|
||
it('подмена массива-позиции ломает подпись → 0', () => {
|
||
const s = sign3('P1', [1, 1], SK);
|
||
const tampered = { ...s, ptr: [1, 0] };
|
||
expect(resolve3(tampered, 'P1', (x) => verify3(x, SK))).toBe(0);
|
||
});
|
||
it('мусор-форма ptr → 0 (SE-3 secure default)', () => {
|
||
expect(resolve3({ plan_id: 'P1', ptr: 'x' }, 'P1')).toBe(0);
|
||
expect(resolve3({ plan_id: 'P1', ptr: [1, 'a'] }, 'P1')).toBe(0);
|
||
});
|
||
it('легаси-целое по-прежнему работает (обратная совместимость)', () => {
|
||
expect(resolve3({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
|
||
});
|
||
});
|
||
|
||
// ── R-08: decide ходит по дереву; SE-1 явный advance; flat-регрессия ──
|
||
import { decide as decideTree } from './enforce-supreme-gate.mjs';
|
||
import { freezePlan as fp4, verifyFrozenPlan as vfp4 } from './plan-lock.mjs';
|
||
|
||
const K4 = 'k4';
|
||
const TREE4 = [
|
||
{ n: 1, op: 'Write', object: 'tools/a.mjs' },
|
||
{ n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'tools/x.mjs' }, { n: '2.2', op: 'Bash', object: 'npx vitest' }] },
|
||
];
|
||
const norm4 = (p) => p.toLowerCase();
|
||
|
||
describe('R-08 decide — дерево', () => {
|
||
const plan = fp4({ steps: TREE4, key: K4, nowMs: 1 });
|
||
it('указатель 0 (целое) → матч первого листа, advance:true', () => {
|
||
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advance).toBe(true);
|
||
});
|
||
it('указатель 1 (контейнер) → спуск, матчит лист 2.1, следующий 2.2', () => {
|
||
const r = decideTree({ toolUse: { name: 'Edit', input: { file_path: 'tools/x.mjs' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advanceTo).toEqual([1, 1]);
|
||
});
|
||
it('действие не лист текущей позиции → block', () => {
|
||
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
it('контейнер сам действие не матчит (SE-2): Write на метку контейнера → block', () => {
|
||
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: '2' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
it('битое дерево (контейнер с op) → block (validatePlanTree)', () => {
|
||
const bad = fp4({ steps: [{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }], key: K4, nowMs: 1 });
|
||
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'a' } }, frozenPlan: bad, stepPtr: 0, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
});
|
||
|
||
describe('R-08 flat-регрессия decide (SE-7)', () => {
|
||
const flat = fp4({ steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], key: K4, nowMs: 1 });
|
||
it('плоский план: advanceTo целое (как раньше), advance:true', () => {
|
||
const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: flat, stepPtr: 0, key: K4, normalize: norm4 });
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advanceTo).toBe(1);
|
||
expect(r.advance).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('SE-2 — decideMode fail-closed whitelist по judge_mode', () => {
|
||
const planWith = (mode) => ({ judge_mode: mode, artifact_id: 'AID', steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }] });
|
||
const artWith = (mode) => ({ judge_mode: mode, artifact_id: 'AID', sections: {} });
|
||
const baseSE2 = (over) => ({ key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => p.toLowerCase(), stepPtr: 0, ...over });
|
||
const writeFoo = { name: 'Write', input: { file_path: 'tools/foo.mjs' } };
|
||
const readX = { name: 'Read', input: { file_path: 'x' } };
|
||
|
||
it('обе печати live-block → энфорсит (совпавшая правка allow)', () => {
|
||
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('live-block'), frozenArtifact: artWith('live-block') }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('implementation');
|
||
});
|
||
it('shadow-план → разговорный, мутатор block', () => {
|
||
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('shadow'), frozenArtifact: artWith('live-block') }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('shadow-печать → observe (Read) allow', () => {
|
||
const r = decideMode(baseSE2({ toolUse: readX, frozenPlan: planWith('shadow'), frozenArtifact: artWith('shadow') }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('judge_mode null/отсутствует → разговорный, мутатор block (fail-closed)', () => {
|
||
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith(null), frozenArtifact: artWith(null) }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('опечатка режима (Shadow / live_block) → разговорный (не live-block)', () => {
|
||
expect(decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('Shadow'), frozenArtifact: artWith('live-block') })).decision).toBe('block');
|
||
expect(decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('live_block'), frozenArtifact: artWith('live-block') })).decision).toBe('block');
|
||
});
|
||
it('mode-mismatch (артефакт live-block + план shadow) → block', () => {
|
||
const r = decideMode(baseSE2({ toolUse: writeFoo, frozenPlan: planWith('shadow'), frozenArtifact: artWith('live-block') }));
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
});
|