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>
120 lines
7.4 KiB
JavaScript
120 lines
7.4 KiB
JavaScript
#!/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: запись в обычный файл' };
|
||
}
|