b6d06ede87
Блок 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>
130 lines
6.2 KiB
JavaScript
130 lines
6.2 KiB
JavaScript
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);
|
||
});
|
||
});
|