From b6d06ede87f65fff66c96ef41d813bee84cb6f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 7 Jun 2026 11:42:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(m5):=20=D0=9F=D0=B0=D0=BA=D0=B5=D1=82=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=20(floor-decide=20+=20enforce-floor=20+=20?= =?UTF-8?q?=D0=B4=D0=B2=D0=B5=D1=80=D1=8C=20=CE=941=20+=20=CE=947=20+=20C4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок 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) --- tools/enforce-floor.mjs | 39 +++++++ tools/enforce-floor.test.mjs | 37 +++++++ tools/enforce-router-gate.mjs | 6 +- tools/enforce-router-gate.test.mjs | 10 +- tools/enforce-supreme-gate-floor.test.mjs | 40 +++++++ tools/enforce-supreme-gate.mjs | 8 ++ tools/floor-decide.mjs | 119 ++++++++++++++++++++ tools/floor-decide.test.mjs | 129 ++++++++++++++++++++++ 8 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 tools/enforce-floor.mjs create mode 100644 tools/enforce-floor.test.mjs create mode 100644 tools/enforce-supreme-gate-floor.test.mjs create mode 100644 tools/floor-decide.mjs create mode 100644 tools/floor-decide.test.mjs diff --git a/tools/enforce-floor.mjs b/tools/enforce-floor.mjs new file mode 100644 index 00000000..5a9be244 --- /dev/null +++ b/tools/enforce-floor.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * enforce-floor (Машина 5, Блок 1) — обёртка несущего пола. Matcher '*' + * (регистрация в settings.json — шаг ВЛАДЕЛЬЦА, ОТДЕЛЬНО от верховной стены М2: + * снятие стены не снимает пол; чтобы пробить необратимое — нужно снять оба). + * + * Зовёт floor-decide ПЕРВЫМ (до seed/observe/членства в плане). Дверь владельца — + * read-only approve_git_operation через shell-content::loadApprovedGitOps (тот же + * window+read-only механизм, что у router-gate; floor НЕ потребляет — F5 гонки нет). + * + * НЕ импортирует plan-lock (Δ9: пол первее плана). fail-CLOSED: любая ошибка → block. + */ +import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; +import { floorDecide } from './floor-decide.mjs'; +import { loadApprovedGitOps } from './shell-content-rules.mjs'; + +/** Чистое решение: делегирует floor-decide. approvedGitOps/now/normalizeImpl инъектируемы. */ +export function decide({ event, approvedGitOps = [], now = Date.now(), normalizeImpl }) { + const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} }; + const args = { toolUse, approvedGitOps, now }; + if (normalizeImpl) args.normalizeImpl = normalizeImpl; + return floorDecide(args); +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + const sess = (event && event.session_id) || 'unknown'; + const approvedGitOps = loadApprovedGitOps(sess); // read-only, window-filtered + const r = decide({ event, approvedGitOps }); + exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined }); + } catch { + exitDecision({ block: true, message: '[floor] внутренняя ошибка — fail-CLOSED' }); + } +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/enforce-floor.test.mjs b/tools/enforce-floor.test.mjs new file mode 100644 index 00000000..71a01d42 --- /dev/null +++ b/tools/enforce-floor.test.mjs @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { decide } from './enforce-floor.mjs'; + +// enforce-floor — тонкая обёртка floor-decide. decide() чистая (approvedGitOps инъект). +const ev = (tool_name, tool_input) => ({ tool_name, tool_input, session_id: 's1' }); + +describe('enforce-floor.decide — делегирует floor-decide', () => { + it('необратимая Bash без одобрения → block', () => { + const r = decide({ event: ev('Bash', { command: 'php artisan migrate:fresh' }), approvedGitOps: [] }); + expect(r.block).toBe(true); + }); + it('обычная Bash → не block', () => { + const r = decide({ event: ev('Bash', { command: 'git status' }), approvedGitOps: [] }); + expect(r.block).toBe(false); + }); + it('Read → не block', () => { + const r = decide({ event: ev('Read', { file_path: '/home/u/.env' }), approvedGitOps: [], normalizeImpl: (s) => s }); + expect(r.block).toBe(false); + }); + it('дверь владельца: свежее одобрение точной команды → не block', () => { + const now = 1_000_000; + const r = decide({ event: ev('Bash', { command: 'php artisan db:wipe' }), approvedGitOps: [{ command: 'php artisan db:wipe', ts: now - 1000 }], now }); + expect(r.block).toBe(false); + }); +}); + +describe('enforce-floor — пол первее плана (не импортирует plan-lock)', () => { + it('исходник enforce-floor.mjs не ИМПОРТИРУЕТ plan-lock (Δ9: floor до плана)', () => { + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(dir, 'enforce-floor.mjs'), 'utf8'); + // таргетим именно import-стейтмент, не упоминание в комментарии + expect(/(?:import|require)\b[^\n]*['"][^'"]*plan-lock/.test(src)).toBe(false); + }); +}); diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index ef22bc6e..db87f186 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -91,7 +91,11 @@ const SAFE_EXACT = [ // Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk). // Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected. // `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:` from matching bare `migrate`. - /^php\s+artisan\s+(?:test|migrate:fresh|migrate:rollback|migrate:refresh|migrate:reset|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/, + // Машина 5 Пакет 2.4 (C4): migrate:fresh/refresh/reset УБРАНЫ из whitelist — это floor-набор + // (необратимый дроп БД, classify-destructive.mjs). Теперь они → default-deny router-gate'ом + // даже при незарегистрированном enforce-floor (защита-в-глубину SPOF). Остаются bare migrate + // (миграции вперёд) + migrate:rollback (обратимо). + /^php\s+artisan\s+(?:test|migrate:rollback|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/, /^composer\s+(?:test|pint|stan|insights|rector)\b/, /^(?:\.\/)?vendor\/bin\/pest\b/, /^pest\b/, diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 52207c07..05ffebec 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -168,10 +168,7 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', 'php artisan test', 'php artisan test --filter=Auth', 'php artisan migrate', - 'php artisan migrate:fresh', 'php artisan migrate:rollback', - 'php artisan migrate:refresh', - 'php artisan migrate:reset', 'php artisan db:seed', 'php artisan cache:clear', 'php artisan config:clear', @@ -191,7 +188,9 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', expect(classifyBashCommand(cmd, {}).result).toBe('allow'); }); - // Critical: REPL and composer mutations remain hard-blocked + // Critical: REPL and composer mutations remain hard-blocked. + // Машина 5 Пакет 2.4: migrate:fresh/refresh/reset убраны из whitelist (floor-territory, + // см. classify-destructive.mjs floor-набор) → router-gate default-deny даже без floor-хука. it.each([ ['php artisan tinker', 'REPL = arbitrary PHP exec risk'], ['php artisan tinker --execute="exit"', 'tinker variant'], @@ -200,6 +199,9 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', ['composer update', 'hard-blacklist'], ['composer remove foo/bar', 'hard-blacklist'], ['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'], + ['php artisan migrate:fresh', 'floor-territory — default-deny после Пакет 2.4'], + ['php artisan migrate:refresh', 'floor-territory — default-deny после Пакет 2.4'], + ['php artisan migrate:reset', 'floor-territory — default-deny после Пакет 2.4'], ])('still blocks %s (%s)', (cmd) => { expect(classifyBashCommand(cmd, {}).result).toBe('block'); }); diff --git a/tools/enforce-supreme-gate-floor.test.mjs b/tools/enforce-supreme-gate-floor.test.mjs new file mode 100644 index 00000000..deafaabf --- /dev/null +++ b/tools/enforce-supreme-gate-floor.test.mjs @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-supreme-gate.mjs'; +import { classifyDestructive } from './classify-destructive.mjs'; + +// Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже если разрушительное действие +// СОВПАДАЕТ с шагом замороженного плана, стена НЕ продвигает указатель — пол +// (classify-destructive.mjs) требует двери владельца. При незарегистрированном +// floor-хуке стена всё равно не благословляет снос. + +const key = 'k'; +const args = (object) => ({ + toolUse: { name: 'Bash', input: { command: object } }, + frozenPlan: { plan_id: 'p1', steps: [{ n: 1, op: 'Bash', object }] }, + frozenArtifact: null, + stepPtr: 0, + key, + verifyImpl: () => true, + verifyArtifactImpl: () => true, + normalize: (s) => s, +}); + +describe('decide — Δ7: разрушительное in-plan не благословляется', () => { + const DESTRUCTIVE = ['rm -rf build', 'php artisan migrate:fresh', 'git push --force', 'git reset --hard HEAD~1']; + for (const cmd of DESTRUCTIVE) { + it(`разрушительный in-plan шаг → block, указатель не двигается: ${cmd}`, () => { + expect(classifyDestructive(cmd).floor).toBe(true); // пол считает это необратимым + const r = decide(args(cmd)); + expect(r.decision).toBe('block'); + expect(r.advanceTo).toBeUndefined(); + }); + } + + it('обычный (не-floor, не-observe) in-plan шаг → allow + advanceTo', () => { + // php artisan migrate: не observe-only (не readonly) и не floor (N1) → доходит до allow-пути + expect(classifyDestructive('php artisan migrate').floor).toBe(false); + const r = decide(args('php artisan migrate')); + expect(r.decision).toBe('allow'); + expect(r.advanceTo).toBe(1); + }); +}); diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 8a166a2b..648e2547 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -11,6 +11,7 @@ import { classifyBashCommand, READING_CMDS } from './enforce-router-gate.mjs'; import { tokenizeBash } from './bash-tokenizer.mjs'; import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; import { assertSafeSessionId } from './action-journal.mjs'; +import { classifyDestructive } from './classify-destructive.mjs'; // Узкий технический allowlist загрузки (НЕ «карта критического») — без него // нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode @@ -149,6 +150,13 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 if (!refResolves(step, frozenArtifact)) return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5)` }; } + // Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже валидный шаг плана, который + // САМ ПО СЕБЕ необратим (force-push/migrate:fresh/reset --hard/rm -rf — classify-destructive + // floor-набор), стена НЕ благословляет: указатель не двигается, нужна дверь владельца + // (floor-хук). При незарегистрированном floor стена всё равно не двигает указатель на снос. + if (classifyDestructive(actionOf(toolUse).object).floor) { + return { decision: 'block', reason: `шаг ${step.n}: разрушительное in-plan действие — пол требует двери владельца (стена не двигает указатель, Δ7)` }; + } return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 }; } diff --git a/tools/floor-decide.mjs b/tools/floor-decide.mjs new file mode 100644 index 00000000..88862afa --- /dev/null +++ b/tools/floor-decide.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * floor-decide (Машина 5, Блок 1) — чистое ядро НЕСУЩЕГО ПОЛА: вето-до-плана на + * необратимое. Срабатывает ПЕРВЫМ (обёртка enforce-floor зовёт его до seed/observe/ + * проверки членства в плане). Валидная печать плана для floor-набора нерелевантна. + * + * Два инспектируемых канала (§2-точность): + * - Bash: classifyDestructive(...).floor — whole-string (ловит chaining + quote-hardened + * FLOOR_RE из Пакета 1) И посегментно через tokenizeBash (unquoted-токены нейтрализуют + * кавычки; ловит floor-сегмент за читающим: `cat x && git push --force`). + * - Write/Edit/NotebookEdit: путь записи через resolving pathNormalize → запись в + * ~/.claude/runtime ИЛИ секрет (.env/ключ/cert) → block. fail-CLOSED (normalize + * бросил → block; в отличие от lenient enforce-runtime-write-deny — пол строже). + * + * Дверь владельца Δ1 (узкая): необратимая Bash-операция проходит ТОЛЬКО при наличии + * свежего (≤5 мин) одобрения ТОЧНОЙ команды в approve_git_operation. Читается READ-ONLY + * (F5: floor НЕ потребляет — реальный потребитель shell-content::loadApprovedGitOps тоже + * read-only+window, гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime, + * пишет PostToolUse-хук на реальный AskUser). Полный escape — в М6. + * + * NB (audit-context 2026-06-07): writer approval-записей сейчас НЕ подписывает (HMAC + * signApprovalRecord существует, но не подключён) → интегрити двери держится protected-path + * side-channel, не подписью. force-push дополнительно жёстко блокируется shell-content + * GIT_HARD_PATTERNS даже approved (дверь для force-push мута — защита-в-глубину; дверь + * реально значима для floor-only migrate:fresh/reset/refresh/db:wipe). Подпись подключится + * в writer'е позже (P10-c) — тогда дверь усилится без смены контракта. + * + * Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из + * per-session файла; unknown-сессия → пустой approvedGitOps → дверь закрыта → block. + * fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor. + */ +import { classifyDestructive } from './classify-destructive.mjs'; +import { tokenizeBash } from './bash-tokenizer.mjs'; +import { pathNormalize } from './path-normalization.mjs'; + +const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i; +const SECRET_PATH_RE = [ + /(^|\/)\.env(\.[\w-]+)?$/i, // .env / .env.local / .env.production + /\.(pem|key|p12|pfx)$/i, // приватные ключи / сертификаты + /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i, // ssh-ключи +]; + +const OBSERVE_TOOLS = new Set(['Read', 'Grep', 'Glob']); // только смотрят — floor записи не касается +// B4-выравнивание: писатели несут путь под разными именами полей (как extractPath/actionOf). +const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri']; +const APPROVE_WINDOW_MS = 5 * 60 * 1000; + +function extractWritePath(input) { + if (!input || typeof input !== 'object') return ''; + for (const f of PATH_FIELDS) { + if (typeof input[f] === 'string' && input[f]) return input[f]; + } + return ''; +} + +function normCmd(c) { + return String(c || '').split(/\s+/).filter(Boolean).join(' '); +} + +/** Bash → floor? whole-string (chaining/quotes) ИЛИ любой floor-сегмент (посегментно). */ +export function bashIsFloor(command) { + const raw = String(command || ''); + if (classifyDestructive(raw).floor) return true; + const tok = tokenizeBash(raw); + if (tok && tok.ok && Array.isArray(tok.segments)) { + for (const s of tok.segments) { + if (classifyDestructive((s.tokens || []).join(' ')).floor) return true; + } + } + return false; +} + +/** Дверь владельца: свежее (≤window) одобрение ТОЧНОЙ команды. read-only. */ +export function approvalOpen(command, approvedGitOps, now) { + if (!Array.isArray(approvedGitOps) || approvedGitOps.length === 0) return false; + const target = normCmd(command); + if (!target) return false; + return approvedGitOps.some( + (op) => op && normCmd(op.command) === target && typeof op.ts === 'number' && now - op.ts <= APPROVE_WINDOW_MS, + ); +} + +/** + * Решение пола. block=true → необратимое без двери / запись в секрет-runtime / fail-close. + * @param {object} p + * @param {{name:string,input:object}} p.toolUse + * @param {Array<{command:string,ts:number}>} [p.approvedGitOps] - read-only approve_git_operation + * @param {number} [p.now] + * @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism) + * @returns {{block:boolean, reason:string}} + */ +export function floorDecide({ toolUse, approvedGitOps = [], now = Date.now(), normalizeImpl = pathNormalize }) { + if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' }; + const name = toolUse.name; + const input = toolUse.input || {}; + + if (name === 'Bash') { + if (bashIsFloor(input.command || '')) { + if (approvalOpen(input.command || '', approvedGitOps, now)) { + return { block: false, reason: 'floor: необратимое разрешено дверью владельца (свежее approve_git_operation)' }; + } + return { block: true, reason: 'floor: необратимая команда без двери владельца — заблокировано (вето-до-плана)' }; + } + return { block: false, reason: 'floor: Bash не необратимо' }; + } + + if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' }; + + // P10-a (атака-линза): путь записи проверяется tool-agnostic (как enforce-runtime-write-deny), + // не только для именованных Write/Edit — ловит MCP-writer'ы (.env/runtime под чужим tool-name). + const fp = extractWritePath(input); + if (!fp) return { block: false, reason: 'floor: нет пути записи' }; + let norm; + try { norm = String(normalizeImpl(fp) || ''); } catch { return { block: true, reason: 'floor: путь записи не резолвится — fail-CLOSED' }; } + const slashed = norm.split('\\').join('/'); + if (RUNTIME_RE.test(slashed)) return { block: true, reason: 'floor: запись в ~/.claude/runtime запрещена (side-channel)' }; + if (SECRET_PATH_RE.some((re) => re.test(slashed))) return { block: true, reason: 'floor: запись в секрет (.env/ключ/cert) запрещена' }; + return { block: false, reason: 'floor: запись в обычный файл' }; +} diff --git a/tools/floor-decide.test.mjs b/tools/floor-decide.test.mjs new file mode 100644 index 00000000..141e6019 --- /dev/null +++ b/tools/floor-decide.test.mjs @@ -0,0 +1,129 @@ +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); + }); +});