29287d73c9
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>
255 lines
15 KiB
JavaScript
255 lines
15 KiB
JavaScript
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:/);
|
||
});
|
||
});
|