#!/usr/bin/env node /** * enforce-verify-gate (G1, М5-family) — PreToolUse Bash. На git commit/push требует свежую * ПОДПИСАННУЮ verify-расписку (acceptVerifyReceipt: sig + occurrence>0 + fingerprint==staged-diff). * Заменяет самописный sentinel verify-before-push/verify-record (Класс 1 закрыт). fail-CLOSE * (active → ошибка=блок); escapable через M6 floor_escape; docs-only short-circuit; рубильник * (флаг+ключ; inert $0 до активации владельцем A3). Override-вокабуляр УДАЛЁН (§4.2 правило 4 — * единственная авторизация = escape M6). Регистрация в settings.json — шаг ВЛАДЕЛЬЦА. * * Файл расписки — ЕДИНЫЙ `~/.claude/runtime/verify-receipt.json` (зеркало producer'а; см. * produce-verify-receipt.mjs — producer-CLI не имеет session_id, поэтому не session-scoped; * свежесть держит fingerprint). Escape-гранты — session-scoped (есть session_id из события). */ import { readStdin, parseEventJson, exitDecision, detectGitCommandKind, isDocsOnlyChange, listChangedFiles } from './enforce-hook-helpers.mjs'; import { acceptVerifyReceipt } from './verify-receipt.mjs'; import { verifyGateActive } from './verify-gate-config.mjs'; import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; import { codeFingerprint } from './criterion-green.mjs'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; /** Чистое решение. gate={active,keyMissing}; receipt|null; currentFingerprint; escapeOpen; changedPaths. */ export function decide({ toolName, command, gate, receipt, currentFingerprint, escapeOpen = false, changedPaths = [], key }) { if (toolName !== 'Bash' || typeof command !== 'string') return { block: false }; const kind = detectGitCommandKind(command); if (kind !== 'commit' && kind !== 'push') return { block: false }; if (isDocsOnlyChange(changedPaths)) return { block: false }; if (!gate || !gate.active) { if (gate && gate.keyMissing) { return { block: true, message: '[verify-gate] флаг ON, но ключ подписанта недоступен — fail-CLOSE (A3 не завершён?)' }; } return { block: false }; // inert ($0) } if (escapeOpen) return { block: false }; if (!receipt) { return { block: true, message: `[verify-gate] нет подписанной verify-расписки — прогоните \`node tools/produce-verify-receipt.mjs\` перед \`git ${kind}\`` }; } const r = acceptVerifyReceipt(receipt, key, { currentFingerprint }); if (!r.accepted) { return { block: true, message: `[verify-gate] расписка отклонена (${r.reason}) — пере-прогоните verify (staged-diff изменился / битая подпись)` }; } return { block: false }; } const RECEIPT_FILE = 'verify-receipt.json'; function loadReceipt(dir) { const path = join(dir, RECEIPT_FILE); if (!existsSync(path)) return null; try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; } } function currentStagedFingerprint(gitCwd) { const files = execFileSync('git', ['-C', gitCwd, 'diff', '--staged', '--name-only'], { encoding: 'utf-8' }) .split(/\r?\n/).map((s) => s.trim()).filter(Boolean); const map = {}; for (const f of files) { try { map[f] = readFileSync(join(gitCwd, f), 'utf-8'); } catch { /* skip */ } } return codeFingerprint(map); } async function main() { let event, gate; try { event = parseEventJson(await readStdin()); gate = verifyGateActive(); } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe ($0) // Гейт не активен и ключ не «пропал» → дешёвый allow без git/fs (inert $0). if (!gate.active && !gate.keyMissing) { exitDecision({ block: false }); return; } try { const command = (event.tool_input && event.tool_input.command) || ''; const kind = detectGitCommandKind(command); const isGit = kind === 'commit' || kind === 'push'; const changedPaths = isGit ? listChangedFiles(kind) : []; const dir = join(homedir(), '.claude', 'runtime'); const receipt = loadReceipt(dir); const currentFingerprint = isGit ? currentStagedFingerprint(process.cwd()) : null; const sess = event.session_id || 'unknown'; const action = canonicalAction('Bash', { command }); const escapeOpen = escapeGrantOpen(action, loadFloorEscapes(sess), loadConsumed(sess)); const r = decide({ toolName: event.tool_name, command, gate, receipt, currentFingerprint, escapeOpen, changedPaths, key: resolveReceiptKey() }); if (r.block) logGuardBlock(event, 'G1 Verify-gate', r.message); exitDecision({ block: r.block, message: r.block ? r.message : undefined }); } catch { exitDecision({ block: true, message: '[verify-gate] внутренняя ошибка — fail-CLOSED' }); // active → fail-CLOSE } } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();