Files
brain/tools/escape-grant.test.mjs
T

140 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<normalized command>', () => {
expect(canonicalAction('Bash', { command: 'git push --force' }, { normalizeImpl: ID }))
.toBe('bash:git push --force');
});
it('Write → write:<normalized path>', () => {
expect(canonicalAction('Write', { file_path: '/a/.env' }, { normalizeImpl: ID }))
.toBe('write:/a/.env');
});
it('MCP → mcp:<tool>:<args>', () => {
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:/);
});
});