// 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