bbc053e0a6
Деплой, помеченный **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>
183 lines
13 KiB
JavaScript
183 lines
13 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 — пол строже).
|
||
*
|
||
* Аварийный выход владельца (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: запись в обычный файл' };
|
||
}
|