Files
brain/tools/escape-grant.test.mjs
T
Дмитрий 29287d73c9 feat: деплой и коммит — только терминальный грант владельца (consent forgery B3)
ops-runbook:<hash> (деплой) и commit:<hash> (коммит агентом) открываются ТОЛЬКО
терминальным грантом владельца (origin:owner-terminal + валидная подпись, ключ обязателен —
fail-closed #KEY), не chat floor_escape — Поза 1. loadPlanScopedGrants переписан как
loadTerminalGrants + фильтр по префиксу (origin/подпись/нижняя граница в одном месте, DRY).
Мосты blessed-ops/commit-grant не тронуты (читают через те же обёртки). Тесты загрузчиков
переписаны под новый контракт. Спека §B/§KEY/§CRIT6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:39:48 +03:00

255 lines
15 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';
import { loadOpsRunbookGrants, opsRunbookGrantOpen, OPS_RUNBOOK_PREFIX } from './escape-grant.mjs';
import { loadCommitGrants, commitGrantOpen, COMMIT_GRANT_PREFIX } from './escape-grant.mjs';
import { loadTerminalGrants, OWNER_TERMINAL_ORIGIN } from './escape-grant.mjs';
import { signFloorEscapeRecord } from './askuser-answer-parser.mjs';
describe('loadTerminalGrants — origin:owner-terminal + валидная подпись (fail-closed, #B/#KEY)', () => {
const KEY = 'tg-key';
const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') });
it('подписанный origin-грант с ключом → принят', () => {
const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY);
expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' }))
.toEqual([{ action: 'owner-seal:abc', ts: 100 }]);
});
it('chat-грант без origin → отвергнут (даже подписанный)', () => {
const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', ts: 100 }, KEY);
expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
it('origin-грант неподписанный → отвергнут (форж контроллера)', () => {
const rec = { type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 };
expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
it('нет ключа → [] (fail-closed #KEY)', () => {
const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY);
expect(loadTerminalGrants('s', 100, { keyImpl: () => null, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
it('future-ts отброшен (нижняя граница)', () => {
const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:xyz', origin: OWNER_TERMINAL_ORIGIN, ts: 5000 }, KEY);
expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
});
describe('commit грант (D2 — окно = существование плана)', () => {
const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') });
it('COMMIT_GRANT_PREFIX = "commit:"', () => {
expect(COMMIT_GRANT_PREFIX).toBe('commit:');
});
it('commitGrantOpen: грант на ЭТОТ plan_id → true; чужой → false', () => {
expect(commitGrantOpen('H1', [{ action: 'commit:H1', ts: 1 }])).toBe(true);
expect(commitGrantOpen('H2', [{ action: 'commit:H1', ts: 1 }])).toBe(false);
});
it('commitGrantOpen: пустой/пустой planId → false', () => {
expect(commitGrantOpen('H', [])).toBe(false);
expect(commitGrantOpen('', [{ action: 'commit:', ts: 1 }])).toBe(false);
});
it('loadCommitGrants: терминальный старше 5 мин НЕ отфильтрован; future-ts отброшен; не-commit игнор', () => {
const old = 1000; const now = old + 10 * 60 * 1000;
const KEY = 'ck';
const term = (action, ts) => signFloorEscapeRecord({ type: 'floor_escape', action, origin: OWNER_TERMINAL_ORIGIN, ts }, KEY);
const fs1 = mkFs([term('commit:H1', old)]);
expect(loadCommitGrants('S', now, { keyImpl: () => KEY, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true);
const fs2 = mkFs([term('commit:H1', 5000)]);
expect(loadCommitGrants('S', 1000, { keyImpl: () => KEY, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]);
const fs3 = mkFs([term('ops-runbook:H1', 1)]);
expect(loadCommitGrants('S', 2, { keyImpl: () => KEY, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]);
});
});
const ID = (s) => s; // normalizeImpl-заглушка для путей
describe('ops-runbook грант (D1 — окно = существование плана, не 5 мин)', () => {
const mkFs = (records) => ({
existsSync: () => true,
readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n'),
});
it('OPS_RUNBOOK_PREFIX = "ops-runbook:"', () => {
expect(OPS_RUNBOOK_PREFIX).toBe('ops-runbook:');
});
it('opsRunbookGrantOpen: грант на ЭТОТ plan_id → true', () => {
expect(opsRunbookGrantOpen('HASH1', [{ action: 'ops-runbook:HASH1', ts: 1 }])).toBe(true);
});
it('opsRunbookGrantOpen: грант на ЧУЖОЙ хеш → false', () => {
expect(opsRunbookGrantOpen('HASH2', [{ action: 'ops-runbook:HASH1', ts: 1 }])).toBe(false);
});
it('opsRunbookGrantOpen: пустой/не-массив/пустой planId → false', () => {
expect(opsRunbookGrantOpen('H', [])).toBe(false);
expect(opsRunbookGrantOpen('H', null)).toBe(false);
expect(opsRunbookGrantOpen('', [{ action: 'ops-runbook:', ts: 1 }])).toBe(false);
});
it('loadOpsRunbookGrants: терминальная запись старше 5 мин НЕ отфильтрована (окно = план)', () => {
const old = 1000; const now = old + 10 * 60 * 1000;
const KEY = 'ok';
const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'ops-runbook:HASH1', origin: OWNER_TERMINAL_ORIGIN, ts: old }, KEY)]);
const grants = loadOpsRunbookGrants('S', now, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' });
expect(grants.some((g) => g.action === 'ops-runbook:HASH1')).toBe(true);
});
it('loadOpsRunbookGrants: обычные (не ops-runbook) терминальные floor_escape игнорирует', () => {
const KEY = 'ok';
const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'bash:rm -rf x', origin: OWNER_TERMINAL_ORIGIN, ts: 1 }, KEY)]);
expect(loadOpsRunbookGrants('S', 2, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]);
});
it('loadOpsRunbookGrants: future-ts (ts > now) отброшен (нижняя граница времени)', () => {
const KEY = 'ok';
const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'ops-runbook:HASH1', origin: OWNER_TERMINAL_ORIGIN, ts: 5000 }, KEY)]);
expect(loadOpsRunbookGrants('S', 1000, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]);
});
});
describe('loadPlanScopedGrants терминал-only + fail-closed (Поза 1 B3)', () => {
const KEY = 'ps-key';
const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') });
const term = (action, ts) => signFloorEscapeRecord({ type: 'floor_escape', action, origin: OWNER_TERMINAL_ORIGIN, ts }, KEY);
it('терминальный подписанный commit-грант + ключ → принят', () => {
expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([term('commit:H1', 100)]), runtimeDir: '/rt' })
.some((g) => g.action === 'commit:H1')).toBe(true);
});
it('chat commit-грант без origin → отвергнут (даже подписанный)', () => {
const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:H1', ts: 100 }, KEY);
expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
it('терминальный ops-грант без ключа → [] (fail-closed #KEY)', () => {
expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => null, fsImpl: mkFs([term('ops-runbook:H1', 100)]), runtimeDir: '/rt' })).toEqual([]);
});
it('терминальный origin-грант неподписанный → отвергнут', () => {
const rec = { type: 'floor_escape', action: 'ops-runbook:H1', origin: OWNER_TERMINAL_ORIGIN, ts: 100 };
expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]);
});
});
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:/);
});
});