Files
brain/tools/enforce-verify-gate.mjs
T

92 lines
5.4 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
/**
* 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();