import { describe, it, expect } from 'vitest'; import { floorDecide } from './floor-decide.mjs'; import { classifyDestructive } from './classify-destructive.mjs'; // for-of + it() (пол tdd-real-test-verifier не распознаёт it.each). floorDecide — // чистое ядро вето-до-плана: блокирует необратимое НЕЗАВИСИМО от плана. Дверь // владельца — read-only approve_git_operation (exact+window, НЕ consume). const id = (s) => s; // identity normalize для детерминизма path-тестов const bash = (command) => ({ name: 'Bash', input: { command } }); const write = (file_path) => ({ name: 'Write', input: { file_path } }); describe('floorDecide — вето на необратимое (независимо от плана)', () => { const BLOCK_BASH = [ 'git push --force', 'git push --force-with-lease origin main', 'git push "--force"', // кавычки — нейтрализованы посегментно 'cat x && git push --force', // chaining — whole-string fallback 'php artisan migrate:fresh', 'php artisan migrate:reset', 'php artisan db:wipe', 'rm -rf build', 'git reset --hard HEAD~3', ]; for (const command of BLOCK_BASH) { it(`block для необратимой Bash: ${command}`, () => { const r = floorDecide({ toolUse: bash(command), normalizeImpl: id }); expect(r.block).toBe(true); }); } const ALLOW_BASH = [ 'php artisan migrate', // N1 — обычная миграция не floor 'php artisan migrate:rollback', 'git status', 'git push origin main', // обычный push — не force 'npm run build', ]; for (const command of ALLOW_BASH) { it(`allow для не-floor Bash: ${command}`, () => { const r = floorDecide({ toolUse: bash(command), normalizeImpl: id }); expect(r.block).toBe(false); }); } it('floor согласован с classifyDestructive.floor для одиночной команды', () => { const cmd = 'php artisan migrate:fresh'; expect(classifyDestructive(cmd).floor).toBe(true); expect(floorDecide({ toolUse: bash(cmd), normalizeImpl: id }).block).toBe(true); }); }); describe('floorDecide — запись в секрет/runtime (fail-CLOSED)', () => { const BLOCK_WRITE = [ '/home/u/app/.env', '/home/u/app/.env.production', '/home/u/.ssh/id_rsa', '/home/u/app/cert.pem', '/home/u/.claude/runtime/askuser-decisions-x.jsonl', ]; for (const fp of BLOCK_WRITE) { it(`block записи в секрет/runtime: ${fp}`, () => { const r = floorDecide({ toolUse: write(fp), normalizeImpl: id }); expect(r.block).toBe(true); }); } it('allow записи в обычный файл', () => { const r = floorDecide({ toolUse: write('/home/u/app/tools/foo.mjs'), normalizeImpl: id }); expect(r.block).toBe(false); }); it('normalize бросил → fail-CLOSED (block)', () => { const boom = () => { throw new Error('cannot resolve'); }; const r = floorDecide({ toolUse: write('/whatever'), normalizeImpl: boom }); expect(r.block).toBe(true); }); }); describe('floorDecide — дверь владельца Δ1 (read-only approval, exact+window)', () => { const now = 1_000_000; it('migrate:fresh с валидным свежим одобрением точной команды → allow (дверь)', () => { const cmd = 'php artisan migrate:fresh'; const approvedGitOps = [{ command: cmd, ts: now - 1000 }]; const r = floorDecide({ toolUse: bash(cmd), approvedGitOps, now, normalizeImpl: id }); expect(r.block).toBe(false); }); it('одобрение ЧУЖОЙ команды → block (дверь не открывается)', () => { const approvedGitOps = [{ command: 'php artisan migrate', ts: now - 1000 }]; const r = floorDecide({ toolUse: bash('php artisan migrate:fresh'), approvedGitOps, now, normalizeImpl: id }); expect(r.block).toBe(true); }); it('просроченное одобрение (>5 мин) → block', () => { const cmd = 'php artisan db:wipe'; const approvedGitOps = [{ command: cmd, ts: now - 6 * 60 * 1000 }]; const r = floorDecide({ toolUse: bash(cmd), approvedGitOps, now, normalizeImpl: id }); expect(r.block).toBe(true); }); it('нет одобрений (unknown-сессия → пустой список) → block', () => { const r = floorDecide({ toolUse: bash('php artisan migrate:fresh'), approvedGitOps: [], now, normalizeImpl: id }); expect(r.block).toBe(true); }); // F-6 (аудит 2026-06-07): floor-decide.mjs approvalOpen без нижней границы времени — // одобрение с будущим ts (now - ts < 0 ≤ window) открывало дверь. Свежесть требует ts в прошлом. it('одобрение с будущим ts (часовой сдвиг/подлог) → block (нижняя граница времени)', () => { const cmd = 'php artisan migrate:fresh'; const approvedGitOps = [{ command: cmd, ts: now + 60 * 1000 }]; const r = floorDecide({ toolUse: bash(cmd), approvedGitOps, now, normalizeImpl: id }); expect(r.block).toBe(true); }); }); describe('floorDecide — observe-only / прочее не блокируется', () => { it('Read не блокируется', () => { expect(floorDecide({ toolUse: { name: 'Read', input: { file_path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false); }); it('Grep/Glob не блокируются', () => { expect(floorDecide({ toolUse: { name: 'Grep', input: { path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false); expect(floorDecide({ toolUse: { name: 'Glob', input: { path: '/home/u/.ssh/id_rsa' } }, normalizeImpl: id }).block).toBe(false); }); }); // floor-decide.mjs P10-a — путь записи проверяется tool-agnostic (как enforce-runtime-write-deny), // не только для именованных Write/Edit: MCP-writer в .env/runtime тоже ловится (атака-линза). describe('floorDecide — P10-a: запись через MCP-writer (tool-agnostic путь)', () => { it('MCP-writer в .env → block', () => { const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { path: '/home/u/app/.env' } }, normalizeImpl: id }); expect(r.block).toBe(true); }); it('MCP-writer в ~/.claude/runtime → block', () => { const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { destination: '/home/u/.claude/runtime/x.jsonl' } }, normalizeImpl: id }); expect(r.block).toBe(true); }); it('MCP-writer в обычный файл → не block', () => { const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { path: '/home/u/app/tools/foo.mjs' } }, normalizeImpl: id }); expect(r.block).toBe(false); }); });