import { describe, it, expect } from 'vitest'; import { canonicalAction, escapeGrantOpen, FLOOR_ESCAPE_WINDOW_MS } from './escape-grant.mjs'; const ID = (s) => s; // normalizeImpl-заглушка для путей describe('escape-grant canonicalAction', () => { it('Bash → bash:', () => { expect(canonicalAction('Bash', { command: 'git push --force' }, { normalizeImpl: ID })) .toBe('bash:git push --force'); }); it('Write → write:', () => { expect(canonicalAction('Write', { file_path: '/a/.env' }, { normalizeImpl: ID })) .toBe('write:/a/.env'); }); it('MCP → mcp::', () => { expect(canonicalAction('mcp__x__send', { url: 'http://1.2.3.4' }, { normalizeImpl: ID })) .toBe('mcp:mcp__x__send:{"url":"http://1.2.3.4"}'); }); }); describe('escape-grant escapeGrantOpen', () => { const now = 1_000_000; const fresh = (action) => ({ action, ts: now - 1000 }); it('точное совпадение свежего непогашенного → open', () => { expect(escapeGrantOpen('bash:git push --force', [fresh('bash:git push --force')], [], now)).toBe(true); }); it('несовпавшая строка → closed', () => { expect(escapeGrantOpen('bash:git push --force', [fresh('bash:reset --hard')], [], now)).toBe(false); }); it('погашенный (action в consumed) → closed (one-shot)', () => { const g = fresh('bash:x'); expect(escapeGrantOpen('bash:x', [g], [{ action: 'bash:x', ts: g.ts }], now)).toBe(false); }); it('устаревший (> окна) → closed', () => { expect(escapeGrantOpen('bash:x', [{ action: 'bash:x', ts: now - FLOOR_ESCAPE_WINDOW_MS - 1 }], [], now)).toBe(false); }); it('из будущего (ts > now) → closed', () => { expect(escapeGrantOpen('bash:x', [{ action: 'bash:x', ts: now + 1000 }], [], now)).toBe(false); }); it('пустой список → closed', () => { expect(escapeGrantOpen('bash:x', [], [], now)).toBe(false); }); }); // escape-grant.mjs — единый findOpenGrant (open ↔ consume один предикат свежести, M6 FIX-2) import { findOpenGrant } from './escape-grant.mjs'; describe('escape-grant findOpenGrant (M6 FIX-2)', () => { const now = 1_000_000; it('при дублях возвращает СВЕЖИЙ непогашенный грант, не future-ts', () => { const fresh = { action: 'bash:x', ts: now - 5 }; const future = { action: 'bash:x', ts: now + 5 }; expect(findOpenGrant('bash:x', [future, fresh], [], now)).toEqual(fresh); }); it('нет открытого (только future-ts) → null', () => { expect(findOpenGrant('bash:x', [{ action: 'bash:x', ts: now + 5 }], [], now)).toBe(null); }); it('погашенный → null (one-shot)', () => { const g = { action: 'bash:x', ts: now - 5 }; expect(findOpenGrant('bash:x', [g], [{ action: 'bash:x', ts: g.ts }], now)).toBe(null); }); }); // M7 Task 1.2b (P-2, КРИТ): canonicalAction обязан иметь ветку PowerShell. Без неё все PS-команды // схлопываются в 'write:' (input.command не в PATH_FIELDS) → один escape-грант разблокирует ЛЮБУЮ // PS-команду в окне, а тест специфичности проходил бы зелёным ложно (a===b==='write:'). describe('escape-grant canonicalAction — PowerShell (P-2)', () => { it('PowerShell специфичен (разные команды → разные ключи)', () => { const a = canonicalAction('PowerShell', { command: 'Remove-Item -Recurse -Force C:\\x' }); const b = canonicalAction('PowerShell', { command: 'Invoke-WebRequest https://e.rf' }); expect(a).toMatch(/^powershell:/); expect(a).not.toBe(b); // НЕ оба 'write:' (баг до 1.2b даёт a===b==='write:') expect(a).not.toBe('write:'); }); it('PowerShell нормализует пробелы (тот же ключ при whitespace-дрейфе)', () => { const a = canonicalAction('PowerShell', { command: 'Remove-Item -Recurse -Force C:\\x' }); const b = canonicalAction('PowerShell', { command: 'Remove-Item -Recurse -Force C:\\x' }); expect(a).toBe(b); }); }); describe('canonicalAction тотальна (M7 Фаза 0, правило 7а, SE-I/L6)', () => { for (const bad of [undefined, null, 123, true, 'x']) { it(`не бросает на мусорном toolName=${String(bad)}`, () => { expect(() => canonicalAction(bad, bad)).not.toThrow(); }); } it('не бросает когда геттер input.command кидает (RED до фикса)', () => { const evil = {}; Object.defineProperty(evil, 'command', { get() { throw new Error('boom'); } }); expect(() => canonicalAction('Bash', evil)).not.toThrow(); }); it('не бросает когда injected normalizeImpl кидает (Write)', () => { const boom = () => { throw new Error('boom'); }; expect(() => canonicalAction('Write', { file_path: '/a' }, { normalizeImpl: boom })).not.toThrow(); }); it('регресс: валидные ключи не сломаны', () => { expect(canonicalAction('Bash', { command: 'git status' })).toBe('bash:git status'); expect(canonicalAction('PowerShell', { command: 'Get-ChildItem' })).toBe('powershell:Get-ChildItem'); }); }); import { escapeAllowsEvent } from './escape-grant.mjs'; describe('escapeAllowsEvent — panic-предикат (M7 Фаза 2, правило 7б)', () => { const now = 1_000_000; const ev = (tool_name, tool_input) => ({ tool_name, tool_input }); it('матч-грант на действие события → true', () => { const action = canonicalAction('Bash', { command: 'git push --force' }); const grants = [{ action, ts: now - 1000 }]; expect(escapeAllowsEvent(ev('Bash', { command: 'git push --force' }), grants, [], now)).toBe(true); }); it('несовпавший грант → false', () => { const grants = [{ action: 'bash:reset --hard', ts: now - 1000 }]; expect(escapeAllowsEvent(ev('Bash', { command: 'git push --force' }), grants, [], now)).toBe(false); }); it('пустые гранты → false', () => { expect(escapeAllowsEvent(ev('Bash', { command: 'x' }), [], [], now)).toBe(false); }); it('малформ-event не бросает → false', () => { expect(escapeAllowsEvent(undefined, [], [], now)).toBe(false); expect(escapeAllowsEvent(null, null, null, now)).toBe(false); }); }); // sub-plan E Task 1 (✅O13): escape привязан к КОНКРЕТНОМУ скилу describe('canonicalAction — Skill (✅O13)', () => { it('Skill → skill:<имя в нижнем регистре>, не write:cwd', () => { expect(canonicalAction('Skill', { skill: 'Audit-Context-Building:Audit-Context-Building' })) .toBe('skill:audit-context-building:audit-context-building'); }); it('разные скилы → разные каноны (не агностичны)', () => { expect(canonicalAction('Skill', { skill: 'sharp-edges:sharp-edges' })) .not.toBe(canonicalAction('Skill', { skill: 'variant-analysis:variants' })); }); it('пустой skill → skill: (детерминирован, не cwd)', () => { expect(canonicalAction('Skill', {})).toBe('skill:'); }); it('не-Skill инструменты не затронуты (Bash остаётся bash:)', () => { expect(canonicalAction('Bash', { command: 'ls' })).toMatch(/^bash:/); }); });