Files
brain/tools/escape-grant.mjs
T

141 lines
8.3 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
/**
* 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);
}
/** 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;
}