b47a71c66b
Опечатанный ревью-план (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>
175 lines
11 KiB
JavaScript
175 lines
11 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 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;
|
||
}
|