Files
brain/tools/escape-grant.test.mjs
T
Дмитрий dec0ed502a feat: терминальный грант владельца — примитив (consent forgery B1)
Несомненный канал согласия для тяжёлого (Поза 1): floor_escape с origin:owner-terminal,
подписанный ключом keychain. Скрипт владельца tools/owner-consent.mjs строит+подписывает+
пишет грант (запускает владелец; контроллер не может — нет ключа + floor режет запись runtime).
Читатель loadTerminalGrants принимает ТОЛЬКО origin-помеченные валидно-подписанные гранты,
ключ обязателен (fail-closed, #KEY). Нулевой радиус: живые потребители НЕ тронуты —
подключение (owner-seal/ops-runbook/commit/plan-done/gate3/bash) отдельными заходами B2+.
Спека: specs/2026-06-18-consent-forgery-fix-design.md §B/§KEY.

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

229 lines
13 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 fs1 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: old }]);
expect(loadCommitGrants('S', now, { keyImpl: () => null, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true);
const fs2 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: 5000 }]);
expect(loadCommitGrants('S', 1000, { keyImpl: () => null, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]);
const fs3 = mkFs([{ type: 'floor_escape', action: 'ops-runbook:H1', ts: 1 }]);
expect(loadCommitGrants('S', 2, { keyImpl: () => null, 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 fs = mkFs([{ type: 'floor_escape', action: 'ops-runbook:HASH1', ts: old }]);
const grants = loadOpsRunbookGrants('S', now, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' });
expect(grants.some((g) => g.action === 'ops-runbook:HASH1')).toBe(true);
});
it('loadOpsRunbookGrants: обычные (не ops-runbook) floor_escape игнорирует', () => {
const fs = mkFs([{ type: 'floor_escape', action: 'bash:rm -rf x', ts: 1 }]);
expect(loadOpsRunbookGrants('S', 2, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]);
});
it('loadOpsRunbookGrants: future-ts (ts > now) отброшен (нижняя граница времени)', () => {
const fs = mkFs([{ type: 'floor_escape', action: 'ops-runbook:HASH1', ts: 5000 }]);
expect(loadOpsRunbookGrants('S', 1000, { keyImpl: () => null, fsImpl: fs, 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:/);
});
});