#!/usr/bin/env node /** * escape-grant (Машина 6, Блок 1) — чистое ядро аварийного выхода. * Заменяет узкую git-only «дверь владельца» floor-decide.approvalOpen. * Пропуск kind:"floor_escape" пишет среда (enforce-askuser-answer-parser на реальный * AskUser); контроллер канал не пишет (~/.claude/runtime protected). Одноразовый: * гасится PostToolUse-консьюмером после исполнения (floor-escape-consume). */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { normalizeCommand, verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; import { pathNormalizeSafe } from './path-normalization.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; import fsDefault from 'node:fs'; export const FLOOR_ESCAPE_WINDOW_MS = 5 * 60 * 1000; 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 ''; } /** Каноническая строка действия (binding-ключ). normalizeImpl инъектируем для тестов. * M7 Фаза 0 (правило 7а, SE-I/L6): тотальна — НИКОГДА не бросает (иначе escape-чек * недостижим и баг кирпичит сессию мимо аварийного выхода владельца). pathNormalizeSafe * тотален; внешний try ловит остаточный throw (бросающий getter/Proxy, мусор). */ export function canonicalAction(toolName, toolInput, { normalizeImpl = pathNormalizeSafe } = {}) { try { const name = String(toolName || ''); const input = toolInput || {}; if (name === 'Bash') return `bash:${normalizeCommand(input.command || '')}`; // M7 Task 1.2b (P-2): PowerShell несёт команду в input.command (как Bash), не путь записи. // Без этой ветки PS уходил в write-fallback → пустой путь резолвился в cwd → один escape-грант // разблокировал ЛЮБУЮ PS-команду в окне. Специфичная канон-строка — escape привязан к точной команде. if (name === 'PowerShell') return `powershell:${normalizeCommand(input.command || '')}`; if (name.startsWith('mcp__')) { let args; try { args = JSON.stringify(input); } catch { args = String(input); } return `mcp:${name}:${normalizeCommand(args)}`; } // ✅O13 (router-mentor): escape привязан к КОНКРЕТНОМУ скилу (не write:cwd-агностик). // Канон нормализован (lowercase/trim) — совпадает с тем, что пишет toFloorEscapeRecord. if (name === 'Skill') return `skill:${String(input.skill || '').toLowerCase().trim()}`; const p = extractWritePath(input); return `write:${String(normalizeImpl(p) || '')}`; } catch { // Тотальность: детерминированный безопасный ключ на любой throw — вход всегда доходит до escape-чека. return 'unknown:'; } } /** * Найти СВЕЖИЙ непогашенный грант с точным совпадением строки действия — или null. * Единый предикат свежести (окно + нижняя граница времени + один-раз) для всех * потребителей: open-проверка (стена/пол/egress) И consume (погашение гасит ИМЕННО * тот грант, который открыл, без расхождения при дублях/future-ts). */ export function findOpenGrant(action, grants, consumed, now = Date.now()) { if (!action || !Array.isArray(grants) || grants.length === 0) return null; const isConsumed = (g) => Array.isArray(consumed) && consumed.some( (c) => c && c.action === g.action && c.ts === g.ts); return grants.find( (g) => g && g.action === action && typeof g.ts === 'number' && now - g.ts >= 0 && now - g.ts <= FLOOR_ESCAPE_WINDOW_MS && !isConsumed(g), ) || null; } /** Свежий непогашенный пропуск с точным совпадением строки действия? */ export function escapeGrantOpen(action, grants, consumed, now = Date.now()) { return findOpenGrant(action, grants, consumed, now) !== null; } /** * Panic-предикат (M7 Фаза 2, правило 7б): покрывает ли открытый escape-грант * действие из события инструмента. Тотален (canonicalAction из Фазы 0 не бросает; * внешний try на любой остаток → false). Для panic-веток floor/supreme-gate: * escape оценивается даже когда per-tool-логика бросает (иначе баг = кирпич мимо escape). */ export function escapeAllowsEvent(event, grants, consumed, now = Date.now()) { try { const name = event && event.tool_name; const input = (event && event.tool_input) || {}; return escapeGrantOpen(canonicalAction(name, input), grants, consumed, now); } catch { return false; } } /** * I/O: floor_escape-пропуски сессии (зеркало shell-content::loadApprovedGitOps). M6 FIX-5: * key-gated подпись — ключ есть → оставить только валидно-подписанные (форж/неподписанный * отброшен); нет ключа (truthy) → принять все (текущее поведение, content-floor backstop). * Быстрый путь: пропусков нет → [] БЕЗ резолва ключа (keychain-subprocess не дёргаем на каждый * tool-use в пустом случае; защита идентична — проверять нечего). keyImpl/fsImpl/runtimeDir * инъектируемы для тестов. */ export function loadFloorEscapes(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir); if (records.length === 0) return []; let key = null; try { key = keyImpl(); } catch { key = null; } const verified = key ? records.filter((r) => verifyFloorEscapeRecord(r, key)) : records; return verified .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })) .filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS); } export const OWNER_TERMINAL_ORIGIN = 'owner-terminal'; // Часть B: метка терминального гранта владельца /** * Терминальные гранты владельца (Поза 1, #B): floor_escape с origin:'owner-terminal', * подписанные ключом keychain. Принимаем ТОЛЬКО при валидной подписи — ключ ОБЯЗАТЕЛЕН * (fail-closed, #KEY: тяжёлый грант без проверяемой подписи не доверяем). origin под подписью * (verifyFloorEscapeRecord покрывает всё тело) → контроллер не добавит origin к chat-гранту, а * свой подписать не может (нет ключа) и не запишет канал (floor). Окно не режем здесь — * вызыватель применяет своё (escapeGrantOpen 5мин / planScopedGrantOpen окно=план). * keyImpl/fsImpl/runtimeDir инъектируемы для тестов. */ export function loadTerminalGrants(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter((r) => r.origin === OWNER_TERMINAL_ORIGIN); if (records.length === 0) return []; let key = null; try { key = keyImpl(); } catch { key = null; } if (!key) return []; // fail-closed: нет ключа → терминальный грант не проверить → не принимаем return records .filter((r) => verifyFloorEscapeRecord(r, key)) .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })) .filter((g) => now - g.ts >= 0); // нижняя граница (не future-ts); верхней нет (окно — у вызывателя) } export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; // D1: благословлённый деплой export const COMMIT_GRANT_PREFIX = 'commit:'; // D2: коммит силами агента /** * D1/D2: plan-scoped гранты сессии по ПРЕФИКСУ (ops-runbook/commit) — БЕЗ верхней (5-мин) границы * окна (окно = существование опечатанного плана; вызыватель-мост проверяет печать). Поза 1 (#B): * это ТЯЖЁЛЫЕ согласия → ТОЛЬКО терминальные гранты владельца (origin:'owner-terminal' + валидная * подпись, ключ ОБЯЗАТЕЛЕН — fail-closed #KEY). Источник, origin-фильтр, проверку подписи и нижнюю * границу (future-ts) даёт loadTerminalGrants (B1); здесь — лишь фильтр по префиксу. consumed не * применяем (грант покрывает много операций плана). keyImpl/fsImpl/runtimeDir инъектируемы. */ export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), opts = {}) { return loadTerminalGrants(sessionId, now, opts).filter((g) => typeof g.action === 'string' && g.action.startsWith(prefix)); } /** Открыт ли plan-scoped грант на ЭТОТ plan_id (точное совпадение action=''). */ export function planScopedGrantOpen(prefix, planId, grants) { if (!planId || !Array.isArray(grants)) return false; const target = `${prefix}${planId}`; return grants.some((g) => g && g.action === target); } // D1 (ops-runbook) и D2 (commit) — тонкие обёртки над обобщённым plan-scoped механизмом. export function loadOpsRunbookGrants(sessionId, now = Date.now(), opts = {}) { return loadPlanScopedGrants(sessionId, OPS_RUNBOOK_PREFIX, now, opts); } export function opsRunbookGrantOpen(planId, grants) { return planScopedGrantOpen(OPS_RUNBOOK_PREFIX, planId, grants); } export function loadCommitGrants(sessionId, now = Date.now(), opts = {}) { return loadPlanScopedGrants(sessionId, COMMIT_GRANT_PREFIX, now, opts); } export function commitGrantOpen(planId, grants) { return planScopedGrantOpen(COMMIT_GRANT_PREFIX, planId, grants); } /** I/O: отметки-погашения. */ export function loadConsumed(sessionId) { const path = join(homedir(), '.claude', 'runtime', `floor-escape-consumed-${sessionId || 'unknown'}.jsonl`); if (!existsSync(path)) return []; const out = []; try { for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) { if (!line.trim()) continue; let r; try { r = JSON.parse(line); } catch { continue; } if (r && typeof r.action === 'string' && typeof r.ts === 'number') out.push({ action: r.action, ts: r.ts }); } } catch { return []; } return out; } /** Полные floor_escape-записи сессии (с sig), без stripping. runtimeDir/fsImpl инъектируемы * для тестов; runtimeDir по умолчанию — homedir()/.claude/runtime. Единственный вызыватель — * loadFloorEscapes (заменил прежний generic loadRecords). loadConsumed читает ДРУГОЙ файл. */ function readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir) { const base = runtimeDir || join(homedir(), '.claude', 'runtime'); const path = join(base, `askuser-decisions-${sessionId || 'unknown'}.jsonl`); if (!fsImpl.existsSync(path)) return []; const out = []; try { for (const line of fsImpl.readFileSync(path, 'utf-8').split(/\r?\n/)) { if (!line.trim()) continue; let r; try { r = JSON.parse(line); } catch { continue; } if (r && r.type === 'floor_escape' && typeof r.action === 'string') out.push(r); } } catch { return []; } return out; }