Files
brain/tools/enforce-floor.test.mjs
T
Дмитрий bbc053e0a6 feat: D1 — благословлённый ops-runbook (деплой выполняет агент под ревью)
Деплой, помеченный **Kind:** deploy и опечатанный (наставник+судья GO,
judge_mode=live-block), агент выполняет по белому списку шагов под ОДНИМ
согласием владельца `FLOOR-ESCAPE: ops-runbook:<plan-hash>` — без аварийного
выхода на каждую команду. «Ядерный» набор (rm -rf/force-push/migrate:fresh/
db:wipe) остаётся на per-command escape.

- plan-lock: freezePlan принимает kind (в подписанную базу + хеш, как delivery);
  не-'normal' добавляет поле, обычные планы байт-идентичны старым печатям.
- plan-skills: parsePlanKind (**Kind:** deploy|normal, default normal).
- seal-orchestration: sealablePlan/sealPlan прокидывают kind в печать.
- escape-grant: loadOpsRunbookGrants (окно = существование плана, БЕЗ 5-мин
  фильтра) + opsRunbookGrantOpen (точный матч на plan_id).
- floor-decide: floorDecide получает инъектируемый blessedOps(cmd); content-block
  команда из набора пропускается, ЯДЕРНЫЙ набор (bashIsFloor) исключён из послабления.
- blessed-ops (новый модуль-мост): buildBlessedOps + loadBlessedOpsForSession —
  знает план+пол, чтобы СОХРАНИТЬ Δ9 (enforce-floor не зависит от модуля печати плана).
  Предикат пускает команду только дословно из Bash-листов опечатанного deploy-плана.
- enforce-floor: gated — blessed-ops грузит план/гранты ТОЛЬКО при открытом
  ops-runbook-гранте; без согласия владельца пол плана не касается (Δ9 цел).

План: docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md
Спека: docs/superpowers/specs/2026-06-18-blessed-ops-runbook-design.md §3.1-3.7.
+33 теста, свод 4299 passed / 2 skipped.

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

88 lines
5.1 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { decide } from './enforce-floor.mjs';
import { buildBlessedOps } from './blessed-ops.mjs';
import { freezePlan } from './plan-lock.mjs';
// enforce-floor — тонкая обёртка floor-decide. decide() чистая (approvedGitOps инъект).
const ev = (tool_name, tool_input) => ({ tool_name, tool_input, session_id: 's1' });
describe('enforce-floor.decide прокидывает blessedOps в floorDecideImpl (D1)', () => {
it('blessedOps доходит до floorDecideImpl', () => {
let seen = 'sentinel';
const spy = (args) => { seen = args.blessedOps; return { block: false, reason: 'stub' }; };
const bless = (c) => c === 'composer install';
decide({ event: ev('Bash', { command: 'composer install' }), blessedOps: bless, floorDecideImpl: spy });
expect(seen).toBe(bless);
});
it('без blessedOps — floorDecideImpl не получает поле (обратная совместимость)', () => {
let had = 'sentinel';
const spy = (args) => { had = ('blessedOps' in args) ? args.blessedOps : 'absent'; return { block: false }; };
decide({ event: ev('Bash', { command: 'ls' }), floorDecideImpl: spy });
expect(had).toBe('absent');
});
});
// D1 критерий §5 — сквозной floor-проход благословлённого runbook (decide + предикат из blessed-ops).
describe('D1 критерий §5 — сквозной floor-проход благословлённого runbook', () => {
const K = 'k-e2e';
const plan = freezePlan({ steps: [{ op: 'Bash', object: 'composer install --no-dev' }, { op: 'Bash', object: 'rm -rf storage/cache' }], kind: 'deploy', judgeMode: 'live-block', key: K, nowMs: 1 });
const bless = buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true });
it('content-block ops-шаг плана (composer) → floor пускает', () => {
expect(decide({ event: ev('Bash', { command: 'composer install --no-dev' }), blessedOps: bless }).block).toBe(false);
});
it('ЯДЕРНЫЙ шаг того же плана (rm -rf) → floor блокирует (нужен per-command escape)', () => {
expect(decide({ event: ev('Bash', { command: 'rm -rf storage/cache' }), blessedOps: bless }).block).toBe(true);
});
it('команда НЕ из плана (composer update) → floor блокирует (белый список)', () => {
expect(decide({ event: ev('Bash', { command: 'composer update' }), blessedOps: bless }).block).toBe(true);
});
});
describe('enforce-floor.decide — делегирует floor-decide', () => {
it('необратимая Bash без одобрения → block', () => {
const r = decide({ event: ev('Bash', { command: 'php artisan migrate:fresh' }), approvedGitOps: [] });
expect(r.block).toBe(true);
});
it('обычная Bash → не block', () => {
const r = decide({ event: ev('Bash', { command: 'git status' }), approvedGitOps: [] });
expect(r.block).toBe(false);
});
it('Read → не block', () => {
const r = decide({ event: ev('Read', { file_path: '/home/u/.env' }), approvedGitOps: [], normalizeImpl: (s) => s });
expect(r.block).toBe(false);
});
it('enforce-floor пробрасывает escapeGrants в floorDecide (совпавший пропуск → не block)', () => {
const now = 1_000_000;
const r = decide({ event: ev('Bash', { command: 'php artisan db:wipe' }),
escapeGrants: [{ action: 'bash:php artisan db:wipe', ts: now - 1000 }], escapeConsumed: [], now, normalizeImpl: (x) => x });
expect(r.block).toBe(false);
});
});
describe('enforce-floor — пол первее плана (не импортирует plan-lock)', () => {
it('исходник enforce-floor.mjs не ИМПОРТИРУЕТ plan-lock (Δ9: floor до плана)', () => {
const dir = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(dir, 'enforce-floor.mjs'), 'utf8');
// таргетим именно import-стейтмент, не упоминание в комментарии
expect(/(?:import|require)\b[^\n]*['"][^'"]*plan-lock/.test(src)).toBe(false);
});
});
describe('enforce-floor.decide — panic-ветка (M7 Фаза 2, правило 7б)', () => {
const now = 1_000_000;
const boom = () => { throw new Error('floorDecide boom'); };
it('floorDecide бросает БЕЗ escape → block:true (fail-CLOSED panic)', () => {
const r = decide({ event: ev('Bash', { command: 'git status' }),
escapeGrants: [], escapeConsumed: [], now, floorDecideImpl: boom });
expect(r.block).toBe(true);
});
it('floorDecide бросает + матч escape-грант → block:false (panic-escape чтится)', () => {
const r = decide({ event: ev('Bash', { command: 'git status' }),
escapeGrants: [{ action: 'bash:git status', ts: now - 1000 }], escapeConsumed: [], now, floorDecideImpl: boom });
expect(r.block).toBe(false);
});
});