Files
brain/tools/floor-decide.mjs
T

174 lines
12 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 — пол строже).
*
* Аварийный выход владельца (floor_escape, М6): необратимое действие проходит ТОЛЬКО при
* наличии свежего (≤5 мин) непогашенного пропуска с ТОЧНЫМ совпадением канон-строки
* (escape-grant::canonicalAction). Пропуски читаются READ-ONLY (floor НЕ потребляет —
* one-shot погашение делает отдельный PostToolUse-консьюмер после реального исполнения;
* гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime, пишет
* PostToolUse-хук на реальный AskUser → toFloorEscapeRecord). Сквозной: тот же пропуск
* чтут стена М2 и egress-классификатор.
*
* Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из
* per-session файла; unknown-сессия → пустой список пропусков → блок.
* fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor.
*/
import { classifyDestructive } from './classify-destructive.mjs';
import { tokenizeBash, detectSubshell } from './bash-tokenizer.mjs';
import { pathNormalize } from './path-normalization.mjs';
import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs';
import { matchBashHardBlacklist } from './shell-content-rules.mjs';
import { psContentBlock } from './powershell-destructive.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-ключи
];
// M7 Task 1.4 (P-3, forge-страж): PS-запись в защищённый путь. Поле `command` не парсится
// общим write-стражем (extractWritePath ждёт file_path/path/…), поэтому PS-глагол записи в
// ~/.claude/runtime / .env / секрет подделал бы escape-грант. NB (находка реализации): plan-версия
// тестила whole-string с $-якорем — `.env` в позиции аргумента (-Path app/.env -Value …) терялся.
// Робастно: проверяем КАЖДЫЙ токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE.
// sharp-edges (после 1.4): +алиасы PS-глаголов записи (sc/ac/cpi/copy/mi/move/ni/tee) — иначе
// `sc ~/.claude/runtime/x` (алиас Set-Content) форджит escape-грант мимо литерального глагола.
const PS_WRITE_VERB_RE = /\b(?:Set-Content|Add-Content|Out-File|Copy-Item|New-Item|Tee-Object|Move-Item|sc|ac|cpi|copy|mi|move|ni|tee)\b/i;
function psProtectedWrite(cmd) {
const s = String(cmd || '');
const isWrite = PS_WRITE_VERB_RE.test(s) || /(?:^|[^0-9>&])>{1,2}(?![>&])/.test(s);
if (!isWrite) return false;
const slashed = s.split('\\').join('/');
if (RUNTIME_RE.test(slashed)) return true; // -Path~/.claude/runtime слитно тоже
for (const raw of s.split(/\s+/)) {
const t = raw.replace(/^['"]|['"]$/g, '').split('\\').join('/');
if (RUNTIME_RE.test(t) || SECRET_PATH_RE.some((re) => re.test(t))) return true;
}
return false;
}
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'];
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 '';
}
/** 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;
}
/**
* 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;
// SE-hardening (M7 Task 1.3 sharp-edges): sub-shell ($()/backtick/process-subst/heredoc) =
// произвольное исполнение, не де-обфусцируемое подстрочным матчером (split-assembly
// `$(echo no)$(echo de) -e` собирает интерпретатор только при shell-eval). Рубим как класс,
// независимо от parse-успеха — закрывает дыру ДО Фазы 8 (увольнение router-gate). Escapable;
// router-gate тоже блокирует все sub-shell → 0 новых FP.
if (detectSubshell(raw).found) 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) — сквозной: свежий
* (≤5 мин) непогашенный пропуск с ТОЧНЫМ совпадением канон-строки действия снимает блок
* в любой ветке (Bash-floor и запись в runtime/секрет). Блок-сообщение выводит точный
* canonicalAction — владелец дословно вставляет его как `FLOOR-ESCAPE: <строка>` (G-5).
* @param {object} p
* @param {{name:string,input:object}} p.toolUse
* @param {Array<{action:string,ts:number}>} [p.escapeGrants] - read-only floor_escape-пропуски
* @param {Array<{action:string,ts:number}>} [p.escapeConsumed] - отметки one-shot погашения
* @param {number} [p.now]
* @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism)
* @returns {{block:boolean, reason:string}}
*/
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize }) {
if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' };
const name = toolUse.name;
const input = toolUse.input || {};
const action = canonicalAction(name, input, { normalizeImpl });
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}` };
}
return { block: false, reason: 'floor: Bash не необратимо' };
}
// M7 Task 1.4 + PS single-source: PowerShell — psContentBlock (тонкий floor-предикат над ЕДИНЫМ
// matchPsHardBlacklist = powershell-gate; variant-analysis закрыл дрейф psContentBlock⊂gate) +
// P-3 forge-страж psProtectedWrite (для New-Item-в-протектед, что у́же blacklist'а). Симметрия с
// bashIsContentBlock. Реоткрытие v3.8 F1: PowerShell был вне пола. Escapable.
if (name === 'PowerShell') {
const cmd = input.command || '';
if (psContentBlock(cmd) || psProtectedWrite(cmd)) {
if (escaped()) return { block: false, reason: 'floor: PS content-block снят аварийным выходом (floor_escape)' };
return { block: true, reason: `floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: ${action}` };
}
return { block: false, reason: 'floor: PowerShell не опасно по содержанию' };
}
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; FLOOR-ESCAPE: ${action}` }; }
const slashed = norm.split('\\').join('/');
if (RUNTIME_RE.test(slashed) || SECRET_PATH_RE.some((re) => re.test(slashed))) {
if (escaped()) return { block: false, reason: 'floor: запись разрешена аварийным выходом владельца (floor_escape)' };
return { block: true, reason: `floor: запись в runtime/секрет без аварийного выхода — блок; FLOOR-ESCAPE: ${action}` };
}
return { block: false, reason: 'floor: запись в обычный файл' };
}