From f28bf7b8e9f990506eb9a5eccde5445a2d8b4bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 9 Jun 2026 10:19:44 +0300 Subject: [PATCH] feat(verify-gate): consumer enforce-verify-gate fail-CLOSE (G1 task6) --- tools/enforce-verify-gate.mjs | 89 ++++++++++++++++++++++++++++++ tools/enforce-verify-gate.test.mjs | 41 ++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tools/enforce-verify-gate.mjs create mode 100644 tools/enforce-verify-gate.test.mjs diff --git a/tools/enforce-verify-gate.mjs b/tools/enforce-verify-gate.mjs new file mode 100644 index 00000000..86ca2766 --- /dev/null +++ b/tools/enforce-verify-gate.mjs @@ -0,0 +1,89 @@ +#!/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 { 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() }); + 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(); diff --git a/tools/enforce-verify-gate.test.mjs b/tools/enforce-verify-gate.test.mjs new file mode 100644 index 00000000..70014ae9 --- /dev/null +++ b/tools/enforce-verify-gate.test.mjs @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-verify-gate.mjs'; +import { signVerifyReceipt } from './verify-receipt.mjs'; + +const KEY = 'signer-key'; +const FP = 'a'.repeat(64); +const base = { + toolName: 'Bash', command: 'git push origin main', key: KEY, + currentFingerprint: FP, escapeOpen: false, changedPaths: ['app/x.php'], +}; +const receipt = () => signVerifyReceipt({ code_fingerprint: FP, occurrence: 1 }, KEY); + +describe('enforce-verify-gate / decide', () => { + it('не git commit/push → allow', () => { + expect(decide({ ...base, command: 'git status', gate: { active: true }, receipt: null }).block).toBe(false); + }); + it('docs-only (все .md) → allow (short-circuit)', () => { + expect(decide({ ...base, changedPaths: ['a.md', 'b.md'], gate: { active: true }, receipt: null }).block).toBe(false); + }); + it('флаг OFF (inert) → allow $0', () => { + expect(decide({ ...base, gate: { active: false, keyMissing: false }, receipt: null }).block).toBe(false); + }); + it('флаг ON но ключ пропал → fail-CLOSE block', () => { + expect(decide({ ...base, gate: { active: false, keyMissing: true }, receipt: null }).block).toBe(true); + }); + it('active + нет расписки → block', () => { + expect(decide({ ...base, gate: { active: true }, receipt: null }).block).toBe(true); + }); + it('active + валидная свежая расписка → allow', () => { + expect(decide({ ...base, gate: { active: true }, receipt: receipt() }).block).toBe(false); + }); + it('active + расписка с устаревшим fingerprint (код изменился) → block', () => { + expect(decide({ ...base, gate: { active: true }, receipt: receipt(), currentFingerprint: 'c'.repeat(64) }).block).toBe(true); + }); + it('active + escape владельца открыт → allow (M6 floor_escape)', () => { + expect(decide({ ...base, gate: { active: true }, receipt: null, escapeOpen: true }).block).toBe(false); + }); + it('commit тоже гейтится (не только push)', () => { + expect(decide({ ...base, command: 'git commit -m x', gate: { active: true }, receipt: null }).block).toBe(true); + }); +});