Files
portal/tools/floor-decide.mjs
T

174 lines
12 KiB
JavaScript
Raw Normal View History

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