Files
brain/tools/escape-grant.mjs
T
Дмитрий 29287d73c9 feat: деплой и коммит — только терминальный грант владельца (consent forgery B3)
ops-runbook:<hash> (деплой) и commit:<hash> (коммит агентом) открываются ТОЛЬКО
терминальным грантом владельца (origin:owner-terminal + валидная подпись, ключ обязателен —
fail-closed #KEY), не chat floor_escape — Поза 1. loadPlanScopedGrants переписан как
loadTerminalGrants + фильтр по префиксу (origin/подпись/нижняя граница в одном месте, DRY).
Мосты blessed-ops/commit-grant не тронуты (читают через те же обёртки). Тесты загрузчиков
переписаны под новый контракт. Спека §B/§KEY/§CRIT6.

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

191 lines
12 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);
}
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='<prefix><id>'). */
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;
}