Files
portal/tools/escape-grant.mjs
T

119 lines
6.5 KiB
JavaScript

#!/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 } from './askuser-answer-parser.mjs';
import { pathNormalizeSafe } from './path-normalization.mjs';
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)}`;
}
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). */
export function loadFloorEscapes(sessionId, now = Date.now()) {
return loadRecords(sessionId, 'floor_escape', 'action').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;
}
function loadRecords(sessionId, type, field) {
const path = join(homedir(), '.claude', 'runtime', `askuser-decisions-${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 && r.type === type && typeof r[field] === 'string') out.push({ [field]: r[field], ts: typeof r.ts === 'number' ? r.ts : 0 });
}
} catch { return []; }
return out;
}