Files
portal/tools/floor-decide.test.mjs
T
Дмитрий b6d06ede87 feat(m5): Пакет 2 — несущий пол (floor-decide + enforce-floor + дверь Δ1 + Δ7 + C4)
Блок 1 Машины 5: вето-до-плана на необратимое, независимо от членства в плане.

- tools/floor-decide.mjs — чистое ядро: Bash floor (classifyDestructive whole-string +
  посегментно tokenizeBash — кавычки/chaining нейтрализованы) + tool-agnostic запись
  (P10-a: .env/ключ/cert + ~/.claude/runtime, fail-CLOSED на normalize-throw).
- Дверь владельца Δ1 — read-only approve_git_operation (exact+5мин окно, НЕ consume).
- tools/enforce-floor.mjs — обёртка matcher '*' (регистрация — шаг владельца, ОТДЕЛЬНО
  от стены М2), loadApprovedGitOps read-only, fail-CLOSED, НЕ импортирует plan-lock.
- C4: migrate:fresh/refresh/reset убраны из router-gate whitelist → default-deny даже
  без floor-хука (SPOF-защита); bare migrate + migrate:rollback остаются.
- Δ7: enforce-supreme-gate.decide на allow-пути зовёт classifyDestructive(...).floor —
  разрушительный in-plan шаг НЕ продвигает указатель (стена не благословляет снос).
- Атака-линза: закрыт P10-a-пробел (MCP-writer в .env floor бы пропустил).

Audit-context вскрыл расхождения план↔код (задокументированы в floor-decide JSDoc):
writer approval НЕ подписывает (интегрити = protected-path side-channel, не HMAC);
F5-гонка мнимая (loadApprovedGitOps read-only+window, не consume); force-push доп-блок
shell-content GIT_HARD (дверь для него мут — защита-в-глубину). Дверь = шов под М6.

Регрессия tools-only: 2649 passed + 2 skip (+41). Residual: node-whitelist hole
для записи в runtime (Пакет 4 сужает); base64-обфускация floor (~0.5%, М6).

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

130 lines
6.2 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);
});
});
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);
});
});