Files
portal/tools/floor-decide.test.mjs
T
Дмитрий 849723bc04 fix(m5): F-6 — нижняя граница времени в floor-decide approvalOpen
approvalOpen считал свежим одобрение с будущим ts (now - ts < 0 <= window) — часовой
сдвиг/подлог открывал дверь владельца. Добавлена нижняя граница now - ts >= 0: свежесть =
ts в прошлом И в пределах окна.

Аудит Машины 5 (объектив корректность). TDD RED->GREEN. Регрессия tools-only 2789 + 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:18:44 +03:00

138 lines
6.9 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 { 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);
});
});