29287d73c9
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>
191 lines
12 KiB
JavaScript
191 lines
12 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, 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;
|
||
}
|