Files
brain/tools/floor-decide.mjs
T
Дмитрий bbc053e0a6 feat: D1 — благословлённый ops-runbook (деплой выполняет агент под ревью)
Деплой, помеченный **Kind:** deploy и опечатанный (наставник+судья GO,
judge_mode=live-block), агент выполняет по белому списку шагов под ОДНИМ
согласием владельца `FLOOR-ESCAPE: ops-runbook:<plan-hash>` — без аварийного
выхода на каждую команду. «Ядерный» набор (rm -rf/force-push/migrate:fresh/
db:wipe) остаётся на per-command escape.

- plan-lock: freezePlan принимает kind (в подписанную базу + хеш, как delivery);
  не-'normal' добавляет поле, обычные планы байт-идентичны старым печатям.
- plan-skills: parsePlanKind (**Kind:** deploy|normal, default normal).
- seal-orchestration: sealablePlan/sealPlan прокидывают kind в печать.
- escape-grant: loadOpsRunbookGrants (окно = существование плана, БЕЗ 5-мин
  фильтра) + opsRunbookGrantOpen (точный матч на plan_id).
- floor-decide: floorDecide получает инъектируемый blessedOps(cmd); content-block
  команда из набора пропускается, ЯДЕРНЫЙ набор (bashIsFloor) исключён из послабления.
- blessed-ops (новый модуль-мост): buildBlessedOps + loadBlessedOpsForSession —
  знает план+пол, чтобы СОХРАНИТЬ Δ9 (enforce-floor не зависит от модуля печати плана).
  Предикат пускает команду только дословно из Bash-листов опечатанного deploy-плана.
- enforce-floor: gated — blessed-ops грузит план/гранты ТОЛЬКО при открытом
  ops-runbook-гранте; без согласия владельца пол плана не касается (Δ9 цел).

План: docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md
Спека: docs/superpowers/specs/2026-06-18-blessed-ops-runbook-design.md §3.1-3.7.
+33 теста, свод 4299 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:19:22 +03:00

183 lines
13 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, blessedOps = null }) {
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') {
const cmd = input.command || '';
const nuclear = bashIsFloor(cmd); // classify-destructive floor-набор (rm -rf/force-push/migrate:fresh/db:wipe)
// M7 Task 1.3 (правило 8, V1): content-block по СОДЕРЖАНИЮ — независимо от плана, escapable.
if (bashIsContentBlock(cmd)) {
// D1 (благословлённый ops-runbook §3.3-3.4): content-block (НЕ ядерная) команда дословно из
// опечатанного deploy-плана под открытым ops-runbook:<hash> → пропуск (ОДИН грант на весь
// runbook, не на каждую команду). Ядерный набор (bashIsFloor) ИСКЛЮЧЁН — rm -rf/force-push
// остаются на per-command escape; `rm -rf` (одновременно content-block И floor) не благословляется.
if (!nuclear && typeof blessedOps === 'function' && blessedOps(cmd)) {
return { block: false, reason: 'floor: благословлённый ops-шаг runbook (ops-runbook:<hash>) — пропуск под согласием владельца' };
}
if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' };
return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` };
}
if (nuclear) {
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: запись в обычный файл' };
}