#!/usr/bin/env node /** * enforce-snapshot (М6, Блок 2) — PreToolUse после enforce-floor. Перед разрушительным * (snapshotNeeded) делает git-точку возврата (git stash create + update-ref * refs/floor-snapshots/), пишет restore-points.jsonl. Чистое дерево → ref=HEAD (успех); * реальная ошибка git → fail-CLOSE (block). Пишет в runtime как процесс-хук (легитимно). */ import { appendFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; import { snapshotNeeded, resolveGitState } from './snapshot-decide.mjs'; import { canonicalAction } from './escape-grant.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; function defaultGit() { try { const stashOut = execFileSync('git', ['stash', 'create'], { encoding: 'utf-8' }); const headOut = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }); return { stashOut, headOut, error: false }; } catch { return { stashOut: '', headOut: '', error: true }; } } function defaultWrite(rec) { const path = join(homedir(), '.claude', 'runtime', 'restore-points.jsonl'); try { mkdirSync(dirname(path), { recursive: true }); } catch {} try { appendFileSync(path, JSON.stringify(rec) + '\n'); } catch {} } // FIX-4: уникальный id снимка по умолчанию — ts + pid + монотонный счётчик, чтобы два // разрушительных в одну миллисекунду не записали один и тот же refs/floor-snapshots/ // (update-ref второго клобберил бы точку возврата первого). let __snapSeq = 0; function defaultSnapId(now) { __snapSeq += 1; return `${now}-${process.pid}-${__snapSeq}`; } /** Чистое решение (инъекция git/write/id/now для тестов). */ export function snapshotDecision(event, { gitImpl = defaultGit, writeImpl = defaultWrite, idImpl, now = Date.now() } = {}) { if (!snapshotNeeded(event.tool_name, event.tool_input || {})) return { block: false }; const st = resolveGitState(gitImpl()); if (!st.ok) return { block: true, message: '[snapshot] не смог сделать точку возврата (ошибка git) — действие остановлено' }; const id = (idImpl ? idImpl() : defaultSnapId(now)) + ''; if (gitImpl === defaultGit) { try { execFileSync('git', ['update-ref', `refs/floor-snapshots/${id}`, st.ref]); } catch { return { block: true, message: '[snapshot] update-ref не удался — fail-close' }; } } writeImpl({ ts: now, action: canonicalAction(event.tool_name, event.tool_input || {}), head: st.ref, snapshot_ref: `refs/floor-snapshots/${id}`, restore_command: `git checkout refs/floor-snapshots/${id} -- .` }); return { block: false }; } async function main() { try { const ev = parseEventJson(await readStdin()); const r = snapshotDecision(ev); if (r.block) logGuardBlock(ev, 'М6 Снимок', r.message); exitDecision({ block: r.block, message: r.block ? r.message : undefined }); } catch { exitDecision({ block: false }); } // снимок — страховка; своя инфра-ошибка не должна клинить (но git-ошибка выше = block) } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();