Files
portal/tools/floor-decide.mjs
T
Дмитрий e3a1c84ea9 fix(m5): F-6 — нижняя граница времени в floor-decide approvalOpen
approvalOpen считал свежим одобрение с будущим ts (now - ts < 0 <= window) — часовой
сдвиг/подлог открывал дверь владельца. Добавлена нижняя граница now - ts >= 0: свежесть =
ts в прошлом И в пределах окна.

Аудит Машины 5 (объектив корректность). TDD RED->GREEN. Регрессия tools-only 2789 + 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:18:44 +03:00

123 lines
7.7 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.
#!/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(
// F-6: свежесть = ts в прошлом И в пределах окна. Нижняя граница (now - ts >= 0) отсекает
// одобрение с будущим ts (часовой сдвиг/подлог), которое иначе открывало бы дверь.
(op) => op && normCmd(op.command) === target && typeof op.ts === 'number'
&& now - op.ts >= 0 && 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: запись в обычный файл' };
}