diff --git a/tools/escape-grant.mjs b/tools/escape-grant.mjs index 47c20681..114c48cc 100644 --- a/tools/escape-grant.mjs +++ b/tools/escape-grant.mjs @@ -67,6 +67,22 @@ export function escapeGrantOpen(action, grants, consumed, now = Date.now()) { return findOpenGrant(action, grants, consumed, now) !== null; } +/** + * Panic-предикат (M7 Фаза 2, правило 7б): покрывает ли открытый escape-грант + * действие из события инструмента. Тотален (canonicalAction из Фазы 0 не бросает; + * внешний try на любой остаток → false). Для panic-веток floor/supreme-gate: + * escape оценивается даже когда per-tool-логика бросает (иначе баг = кирпич мимо escape). + */ +export function escapeAllowsEvent(event, grants, consumed, now = Date.now()) { + try { + const name = event && event.tool_name; + const input = (event && event.tool_input) || {}; + return escapeGrantOpen(canonicalAction(name, input), grants, consumed, now); + } catch { + return false; + } +} + /** I/O: floor_escape-пропуски сессии (зеркало shell-content::loadApprovedGitOps). */ export function loadFloorEscapes(sessionId, now = Date.now()) { return loadRecords(sessionId, 'floor_escape', 'action').filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS); diff --git a/tools/escape-grant.test.mjs b/tools/escape-grant.test.mjs index 8a0b0009..10aeaf83 100644 --- a/tools/escape-grant.test.mjs +++ b/tools/escape-grant.test.mjs @@ -97,3 +97,25 @@ describe('canonicalAction тотальна (M7 Фаза 0, правило 7а, S 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); + }); +});