Files
brain/tools/escape-grant.mjs
T
Дмитрий b47a71c66b feat: D2 — канал коммита под ревью (агент коммитит под commit:<hash>)
Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно
согласие владельца `FLOOR-ESCAPE: commit:<plan-hash>` → агент делает git
add/commit/push без терминала владельца. Гейт ПРИСУТСТВИЯ (router-gate
git-approval) отходит; гейты КАЧЕСТВА (criterion-gate/verify-gate) НЕ тронуты —
код-коммит всё равно требует по-критерийный GREEN и свежую расписку. Согласия
деплоя (ops-runbook:) и коммита (commit:) — раздельные кнопки.

- escape-grant: обобщён plan-scoped загрузчик (loadPlanScopedGrants/
  planScopedGrantOpen, окно = существование плана); D1 ops-runbook стал тонкой
  обёрткой; добавлены commit: COMMIT_GRANT_PREFIX/loadCommitGrants/commitGrantOpen.
- commit-grant (новый мост план↔router-gate): commitGrantOpenForSession —
  открыт ли commit:<hash> на валидный sealed live-block план сессии.
- shell-content-rules classifyGitCommand: conditional-git пускается при
  ctx.commitGrantOpen; GIT_HARD (force-push/--no-verify/-c) блокирует ПЕРВЫМ
  (качество/безопасность не ослаблены).
- enforce-router-gate: main кладёт ctx.commitGrantOpen (gated через мост).

План: docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md
Спека: docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md §3.1-3.2.

ОТЛОЖЕНО (требует решения владельца, в хвосте плана):
- §3.3 docs/ops без criterion/verify: .md уже пропускается; расширение на
  не-.md ops-артефакты конфликтует с CLAUDE.md §13 v2.40 — нужен явный список.
- §3.4 десинк push-последним-шагом: рискованная правка снятия печати стены.

+22 теста, свод 4319 passed / 2 skipped.

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

175 lines
11 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 OPS_RUNBOOK_PREFIX = 'ops-runbook:'; // D1: благословлённый деплой
export const COMMIT_GRANT_PREFIX = 'commit:'; // D2: коммит силами агента
/**
* D1/D2: plan-scoped гранты сессии по ПРЕФИКСУ — БЕЗ верхней (5-мин) границы окна (окно =
* существование опечатанного плана; вызыватель-мост проверяет, что план с этим хешем ещё запечатан).
* Нижняя граница времени остаётся (future-ts отбрасываем). Подпись key-gated как loadFloorEscapes.
* Это НЕ one-shot грант (покрывает много операций плана — consumed не применяем). keyImpl/fsImpl/
* runtimeDir инъектируемы для тестов.
*/
export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) {
const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter(
(r) => typeof r.action === 'string' && r.action.startsWith(prefix));
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 >= 0); // нижняя граница (не future-ts); верхней НЕТ (окно = план)
}
/** Открыт ли 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;
}