diff --git a/docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md b/docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md new file mode 100644 index 0000000..4600139 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md @@ -0,0 +1,330 @@ +# Канал коммита под ревью (D2) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно согласие владельца `FLOOR-ESCAPE: commit:` → агент делает git add/commit/push без терминала владельца; гейты КАЧЕСТВА (criterion/verify) остаются нетронуты, гейт ПРИСУТСТВИЯ (router-gate git-approval) отходит. + +**Architecture:** Зеркало D1. `commit:` грант грузится загрузчиком БЕЗ 5-мин окна (окно = существование плана). Мост `commit-grant.mjs` (знает plan-lock+escape-grant) вычисляет `commitGrantOpen` для сессии (валидный sealed live-block план + открытый commit-грант на его хеш). router-gate прокидывает `ctx.commitGrantOpen`; `classifyGitCommand` пускает conditional-git, когда грант открыт. GIT_HARD_PATTERNS (force-push/--no-verify/-c) блокируют ПЕРВЫМ — качество/безопасность не ослаблены. criterion-gate/verify-gate не трогаем. + +**Tech Stack:** Node ESM, vitest (через PowerShell: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`), HMAC-расписки. + +**Спека:** [docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md](../specs/2026-06-18-agent-commit-channel-design.md) §3.1-3.2, критерий §5. + +**Дисциплина:** строго TDD; полный свод через PowerShell после каждого GREEN. Правки машинерии — в ШТАТНОМ. Коммит — дисциплина handoff. + +**ЗАКРЫТО владельцем (handoff):** коммит делает агент; гейты КАЧЕСТВА остаются; гейт ПРИСУТСТВИЯ → `commit:`; согласия деплоя и коммита — РАЗДЕЛЬНЫЕ кнопки (`ops-runbook:` vs `commit:`). + +**ВЫНЕСЕНО В ХВОСТ (требует решения владельца, НЕ в этом плане):** +- §3.3 «docs/ops-коммит без criterion/verify»: docs (`.md`) уже пропускаются текущим `isDocsOnlyPath`. Расширение на не-`.md` «ops-артефакты» КОНФЛИКТУЕТ с CLAUDE.md §13 v2.40 (конфиги ДОЛЖНЫ включать verify) и не определено, какие пути считать ops-артефактом. Нужен явный список от владельца. +- §3.4 десинк (push последним шагом → ложный блок criterion): правка порядка снятия печати в несущей стене (риск для Фазы 5). Отдельная изолированная задача. + +--- + +## File Structure + +- `tools/escape-grant.mjs` — обобщить загрузчик plan-scoped грантов; добавить `COMMIT_GRANT_PREFIX`, `loadCommitGrants`, `commitGrantOpen`. +- `tools/commit-grant.mjs` (новый) — мост: `commitGrantOpenForSession(sess)` → bool (валидный sealed live-block план + открытый commit-грант на его хеш). +- `tools/shell-content-rules.mjs` — `classifyGitCommand`: conditional-git пускается при `ctx.commitGrantOpen`. +- `tools/enforce-router-gate.mjs` — `main()` кладёт `ctx.commitGrantOpen = commitGrantOpenForSession(sess)`. + +--- + +## Task 1: escape-grant — commit-гранты (обобщённый prefix-загрузчик) + +**Files:** +- Modify: `tools/escape-grant.mjs` +- Test: `tools/escape-grant.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```javascript +import { loadCommitGrants, commitGrantOpen, COMMIT_GRANT_PREFIX } from './escape-grant.mjs'; + +describe('commit грант (D2 — окно = существование плана)', () => { + const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); + it('COMMIT_GRANT_PREFIX = "commit:"', () => { + expect(COMMIT_GRANT_PREFIX).toBe('commit:'); + }); + it('commitGrantOpen: грант на ЭТОТ plan_id → true; чужой → false', () => { + expect(commitGrantOpen('H1', [{ action: 'commit:H1', ts: 1 }])).toBe(true); + expect(commitGrantOpen('H2', [{ action: 'commit:H1', ts: 1 }])).toBe(false); + }); + it('commitGrantOpen: пустой/не-массив/пустой planId → false', () => { + expect(commitGrantOpen('H', [])).toBe(false); + expect(commitGrantOpen('', [{ action: 'commit:', ts: 1 }])).toBe(false); + }); + it('loadCommitGrants: запись старше 5 мин НЕ отфильтрована; future-ts отброшен; не-commit игнор', () => { + const old = 1000; const now = old + 10 * 60 * 1000; + const fs1 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: old }]); + expect(loadCommitGrants('S', now, { keyImpl: () => null, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true); + const fs2 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: 5000 }]); + expect(loadCommitGrants('S', 1000, { keyImpl: () => null, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]); + const fs3 = mkFs([{ type: 'floor_escape', action: 'ops-runbook:H1', ts: 1 }]); + expect(loadCommitGrants('S', 2, { keyImpl: () => null, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: RED** — `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (функций нет). + +- [ ] **Step 3: Реализация** — обобщить загрузчик в `tools/escape-grant.mjs`. Заменить тело `loadOpsRunbookGrants` на вызов общего, добавить commit-аналоги: + +```javascript +export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; +export const COMMIT_GRANT_PREFIX = 'commit:'; + +/** D1/D2: plan-scoped гранты сессии по префиксу — БЕЗ верхней (5-мин) границы окна (окно = + * существование опечатанного плана). Нижняя граница времени (не future-ts) остаётся. Подпись + * key-gated. Не 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); +} + +/** Открыт ли plan-scoped грант (точный action = ''). */ +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); +} + +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); } +``` + +(NB: удалить прежние самостоятельные тела `loadOpsRunbookGrants`/`opsRunbookGrantOpen` из D1 — заменены тонкими обёртками. D1-тесты в escape-grant.test.mjs покрывают регресс.) + +- [ ] **Step 4: GREEN + полный свод** — escape-grant.test.mjs зелёный; затем `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` (D1 ops-runbook тесты держатся). + +--- + +## Task 2: commit-grant.mjs — мост (commitGrantOpenForSession) + +**Files:** +- Create: `tools/commit-grant.mjs` +- Test: `tools/commit-grant.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```javascript +import { describe, it, expect } from 'vitest'; +import { commitGrantOpenForSession } from './commit-grant.mjs'; +import { freezePlan } from './plan-lock.mjs'; + +describe('commitGrantOpenForSession (D2 — gated: нет гранта → план не грузим)', () => { + const K = 'k-cg'; + it('нет commit-грантов → false, план НЕ грузится', () => { + let planLoaded = false; + const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [], loadPlanImpl: () => { planLoaded = true; return null; }, keyImpl: () => K, verifyImpl: () => true }); + expect(r).toBe(false); + expect(planLoaded).toBe(false); + }); + it('грант на хеш + sealed live-block план → true', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(r).toBe(true); + }); + it('грант на ЧУЖОЙ хеш → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); + }); + it('judge_mode не live-block → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'shadow', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); + }); + it('печать невалидна (verifyImpl→false) → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => false })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: RED** — `npx vitest run tools/commit-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (модуля нет). + +- [ ] **Step 3: Реализация** — создать `tools/commit-grant.mjs`: + +```javascript +#!/usr/bin/env node +/** + * commit-grant (D2, спека 2026-06-18-agent-commit-channel-design §3.1) — МОСТ план↔router-gate + * для «канала коммита под ревью». Вычисляет: открыт ли commit: грант на ВАЛИДНЫЙ + * опечатанный ревью-план сессии (sealed + judge_mode='live-block'). Если да — router-gate отпускает + * гейт ПРИСУТСТВИЯ (git-approval); гейты КАЧЕСТВА (criterion/verify) остаются. + */ +import { homedir } from 'node:os'; +import { verifyFrozenPlan, loadFrozenPlan } from './plan-lock.mjs'; +import { commitGrantOpen, loadCommitGrants } from './escape-grant.mjs'; +import { resolveReceiptKey } from './receipt-key-config.mjs'; + +export function commitGrantOpenForSession(sessionId, { + loadGrantsImpl = loadCommitGrants, + loadPlanImpl = loadFrozenPlan, + keyImpl = resolveReceiptKey, + verifyImpl = verifyFrozenPlan, + runtimeDir = `${homedir()}/.claude/runtime`, +} = {}) { + const grants = loadGrantsImpl(sessionId); + if (!Array.isArray(grants) || grants.length === 0) return false; + const plan = loadPlanImpl({ sessionId, runtimeDir }); + if (!plan || !plan.plan_id) return false; + if (!commitGrantOpen(plan.plan_id, grants)) return false; + if (plan.judge_mode !== 'live-block') return false; // одобрение к энфорсменту + const key = keyImpl(); + if (!verifyImpl(plan, key)) return false; // печать цела + return true; +} +``` + +- [ ] **Step 4: GREEN + полный свод.** + +--- + +## Task 3: classifyGitCommand честит commit-грант + router-gate wiring + +**Files:** +- Modify: `tools/shell-content-rules.mjs` (`classifyGitCommand`) +- Modify: `tools/enforce-router-gate.mjs` (`main` ctx) +- Test: `tools/shell-content-rules.test.mjs`, `tools/enforce-router-gate.test.mjs` + +- [ ] **Step 1: Падающий тест (classifyGitCommand)** + +В `tools/shell-content-rules.test.mjs`: + +```javascript +describe('classifyGitCommand commit-грант (D2)', () => { + it('git commit при ctx.commitGrantOpen → allow (присутствие отходит)', () => { + const r = classifyGitCommand('git commit -m x', { commitGrantOpen: true }); + expect(r.result).toBe('allow'); + expect(r.reason).toMatch(/commit-грант|commit:|ревью-план/i); + }); + it('git push при ctx.commitGrantOpen → allow', () => { + expect(classifyGitCommand('git push gitea main', { commitGrantOpen: true }).result).toBe('allow'); + }); + it('без гранта и без approve → block (default-deny держит)', () => { + expect(classifyGitCommand('git commit -m x', {}).result).toBe('block'); + }); + it('GIT_HARD (force-push) блокирует ДАЖЕ при commitGrantOpen (качество/безопасность)', () => { + expect(classifyGitCommand('git push --force', { commitGrantOpen: true }).result).toBe('block'); + }); + it('--no-verify блокируется при commitGrantOpen', () => { + expect(classifyGitCommand('git commit --no-verify -m x', { commitGrantOpen: true }).result).toBe('block'); + }); +}); +``` + +- [ ] **Step 2: RED** — `npx vitest run tools/shell-content-rules.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (commitGrantOpen игнорируется → commit блокируется). + +- [ ] **Step 3: Реализация** — в `tools/shell-content-rules.mjs`, `classifyGitCommand`, conditional-ветка: + +```javascript + // 3. conditional → approve check ИЛИ commit: грант (D2: гейт присутствия отходит). + // GIT_HARD_PATTERNS уже отсеяли force-push/--no-verify/-c ВЫШЕ — качество/безопасность не ослаблены. + if (GIT_CONDITIONAL_SUB.has(sub)) { + if (ctx.commitGrantOpen === true) return { result: 'allow', reason: `git ${sub}: подтверждено commit: (одно согласие на ревью-план)` }; + const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now()); + if (approved) return { result: 'allow', reason: `git ${sub}: подтверждено approve_git_operation` }; + return { result: 'block', reason: `git ${sub} требует AskUser approval (approve_git_operation). Запросите подтверждение и повторите.` }; + } +``` + +- [ ] **Step 4: GREEN (shell-content-rules).** + +- [ ] **Step 5: Падающий тест (router-gate wiring)** + +В `tools/enforce-router-gate.test.mjs` — проверить, что `classifyBashCommand` пускает git commit при `ctx.commitGrantOpen:true`: + +```javascript +describe('router-gate ctx.commitGrantOpen (D2 wiring)', () => { + it('git commit + commitGrantOpen:true → allow', () => { + const r = classifyBashCommand('git commit -m x', { commitGrantOpen: true }); + expect(r.result).toBe('allow'); + }); + it('git commit без гранта → block', () => { + expect(classifyBashCommand('git commit -m x', {}).result).toBe('block'); + }); +}); +``` + +(NB: `classifyBashCommand` уже прокидывает `ctx` целиком в `classifyGitCommand` через `classifyGitCommand(command, ctx)` — wiring на уровне ctx-объекта; проверить, что ctx прокинут. Если в тест-файле нет импорта `classifyBashCommand` — добавить из enforce-router-gate.mjs.) + +- [ ] **Step 6: RED → проверить.** Если уже GREEN (ctx прокидывается насквозь) — тест характеризующий, поведение даёт Task 3 Step 3. Если RED (ctx не доходит) — Step 7. + +- [ ] **Step 7: Реализация router-gate main** — в `tools/enforce-router-gate.mjs`, `main()`, добавить в ctx: + +```javascript + let commitGrantOpen = false; + try { const { commitGrantOpenForSession } = await import('./commit-grant.mjs'); commitGrantOpen = commitGrantOpenForSession(sessionId); } catch { commitGrantOpen = false; } + const ctx = { + approvedGitOps: loadApprovedGitOps(sessionId), + editedFiles: readEditedFiles(sessionId), + pathNormalize, + protectedPaths, + commitGrantOpen, + now: Date.now(), + }; +``` + +- [ ] **Step 8: GREEN + полный свод.** + +--- + +## Task 4: Интеграционный критерий §5 + коммит + +**Files:** +- Test: `tools/commit-grant.test.mjs` (или shell-content-rules.test.mjs) — сквозной. + +- [ ] **Step 1: Сквозной тест критерия §5** + +```javascript +import { classifyGitCommand } from './shell-content-rules.mjs'; +import { commitGrantOpenForSession } from './commit-grant.mjs'; +import { freezePlan } from './plan-lock.mjs'; + +describe('D2 критерий §5 — канал коммита под ревью (сквозной)', () => { + const K = 'k-d2e2e'; + const plan = freezePlan({ steps: [{ op: 'Write', object: 'tools/x.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + const open = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + it('опечатанный план + commit: → git commit allow', () => { + expect(open).toBe(true); + expect(classifyGitCommand('git commit -m x', { commitGrantOpen: open }).result).toBe('allow'); + }); + it('без гранта на этот хеш → git commit block (default-deny держит)', () => { + const noGrant = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(classifyGitCommand('git commit -m x', { commitGrantOpen: noGrant }).result).toBe('block'); + }); +}); +``` + +- [ ] **Step 2: GREEN (характеризующий — код из Task 1-3).** + +- [ ] **Step 3: Финальный полный свод + коммит** по дисциплине handoff (явные пути; `node tools/produce-verify-receipt.mjs` через PowerShell → signed GREEN; `.git/CB_MSG.txt`; `git commit -F`). + +--- + +## Хвосты (требуют решения владельца — НЕ в этом плане) + +- **§3.3 docs/ops без criterion/verify:** docs (`.md`) уже пропускаются. Для не-`.md` «ops-артефактов» нужен явный список путей от владельца (конфликт с CLAUDE.md §13 v2.40 — конфиги ДОЛЖНЫ включать verify). Спросить. +- **§3.4 десинк** (push последним шагом → ложный блок criterion-gate): изолированная правка порядка снятия печати в supreme-gate/Фаза 5. Рискованно — отдельная задача с воспроизводящим тестом. +- **escape-parser commit::** `toFloorEscapeRecord` уже захватывает любую `FLOOR-ESCAPE: ` → `commit:` пишется без правок парсера (как ops-runbook). Подтверждено чтением кода — отдельной задачи не нужно. +- **Норматив/GUIDE:** «коммит силами агента под commit:» — в Pravila/GUIDE через claude-md-management, когда стена вернётся. + +--- + +## Self-Review + +**1. Покрытие §5 (закрытая владельцем сердцевина):** +- ✅ Опечатанный план + commit: → агент git commit/push (Task 2+3+4). +- ✅ Без commit: → router-gate block (Task 3, default-deny). +- ✅ Код-коммит всё равно требует GREEN-критериев и расписки — criterion/verify НЕ трогаем (по дизайну). +- ✅ GIT_HARD (force-push/--no-verify) блокируют даже при гранте (Task 3). +- ⚠️ §3.3 docs/ops, §3.4 десинк — вынесены в хвост (underspec/risk), см. выше. + +**2. Плейсхолдеры:** код во всех шагах конкретный. + +**3. Согласованность:** `commitGrantOpen` — имя ctx-поля (Task 3) И return commitGrantOpenForSession (Task 2). `commit:` префикс/`COMMIT_GRANT_PREFIX`/`loadCommitGrants`/`commitGrantOpen` (Task 1) → используются в Task 2. Обобщённый `loadPlanScopedGrants`/`planScopedGrantOpen` (Task 1) переиспользуют D1 ops-runbook (тонкие обёртки, D1-тесты держат регресс). diff --git a/tools/commit-grant.mjs b/tools/commit-grant.mjs new file mode 100644 index 0000000..6155942 --- /dev/null +++ b/tools/commit-grant.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * commit-grant (D2, спека 2026-06-18-agent-commit-channel-design §3.1) — МОСТ план↔router-gate + * для «канала коммита под ревью». Вычисляет: открыт ли commit: грант на ВАЛИДНЫЙ + * опечатанный ревью-план сессии (sealed + judge_mode='live-block'). Если да — router-gate отпускает + * гейт ПРИСУТСТВИЯ (git-approval); гейты КАЧЕСТВА (criterion-gate/verify-gate) остаются нетронуты. + * + * Брат blessed-ops (D1): тот же приём — мост знает оба слоя, зовётся лишь когда владелец дал согласие. + */ +import { homedir } from 'node:os'; +import { verifyFrozenPlan, loadFrozenPlan } from './plan-lock.mjs'; +import { commitGrantOpen, loadCommitGrants } from './escape-grant.mjs'; +import { resolveReceiptKey } from './receipt-key-config.mjs'; + +/** + * Открыт ли commit-канал для сессии. Грузит commit-гранты ПЕРВЫМ; нет грантов → false БЕЗ загрузки + * плана/ключа. Есть грант → план обязан быть опечатан (verify), judge_mode='live-block' (GO к + * энфорсменту) и грант — именно на его plan_id. Всё инъектируемо для теста. + */ +export function commitGrantOpenForSession(sessionId, { + loadGrantsImpl = loadCommitGrants, + loadPlanImpl = loadFrozenPlan, + keyImpl = resolveReceiptKey, + verifyImpl = verifyFrozenPlan, + runtimeDir = `${homedir()}/.claude/runtime`, +} = {}) { + const grants = loadGrantsImpl(sessionId); + if (!Array.isArray(grants) || grants.length === 0) return false; + const plan = loadPlanImpl({ sessionId, runtimeDir }); + if (!plan || !plan.plan_id) return false; + if (!commitGrantOpen(plan.plan_id, grants)) return false; + if (plan.judge_mode !== 'live-block') return false; // одобрение к энфорсменту + const key = keyImpl(); + if (!verifyImpl(plan, key)) return false; // печать цела + return true; +} diff --git a/tools/commit-grant.test.mjs b/tools/commit-grant.test.mjs new file mode 100644 index 0000000..2d6a70c --- /dev/null +++ b/tools/commit-grant.test.mjs @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { commitGrantOpenForSession } from './commit-grant.mjs'; +import { freezePlan } from './plan-lock.mjs'; +import { classifyGitCommand } from './shell-content-rules.mjs'; + +describe('D2 критерий §5 — канал коммита под ревью (сквозной)', () => { + const K = 'k-d2e2e'; + const plan = freezePlan({ steps: [{ op: 'Write', object: 'tools/x.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + it('опечатанный план + commit: → git commit allow', () => { + const open = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(open).toBe(true); + expect(classifyGitCommand('git commit -m x', { commitGrantOpen: open }).result).toBe('allow'); + }); + it('грант на ЧУЖОЙ хеш → git commit block (default-deny держит)', () => { + const open = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(open).toBe(false); + expect(classifyGitCommand('git commit -m x', { commitGrantOpen: open }).result).toBe('block'); + }); + it('force-push блокируется даже под открытым commit-грантом (качество держит)', () => { + const open = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(classifyGitCommand('git push --force', { commitGrantOpen: open }).result).toBe('block'); + }); +}); + +describe('commitGrantOpenForSession (D2 — gated: нет гранта → план не грузим)', () => { + const K = 'k-cg'; + it('нет commit-грантов → false, план НЕ грузится', () => { + let planLoaded = false; + const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [], loadPlanImpl: () => { planLoaded = true; return null; }, keyImpl: () => K, verifyImpl: () => true }); + expect(r).toBe(false); + expect(planLoaded).toBe(false); + }); + it('грант на хеш + sealed live-block план → true', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); + expect(r).toBe(true); + }); + it('грант на ЧУЖОЙ хеш → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); + }); + it('judge_mode не live-block → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'shadow', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); + }); + it('печать невалидна (verifyImpl→false) → false', () => { + const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => false })).toBe(false); + }); + it('нет плана → false', () => { + expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:x', ts: 1 }], loadPlanImpl: () => null, keyImpl: () => K, verifyImpl: () => true })).toBe(false); + }); +}); diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index 8559fa8..eaeb159 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -192,11 +192,17 @@ async function main() { const cfg = loadConfig(); protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files); } catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ } + // D2 (канал коммита под ревью): открыт ли commit: на валидный sealed live-block план. + // Мост commit-grant грузит план/гранты ТОЛЬКО при наличии commit-гранта; сбой → false (fail-safe: + // нет послабления гейта присутствия). Гейты КАЧЕСТВА (criterion/verify) — отдельные хуки, не здесь. + let commitGrantOpen = false; + try { const { commitGrantOpenForSession } = await import('./commit-grant.mjs'); commitGrantOpen = commitGrantOpenForSession(sessionId); } catch { commitGrantOpen = false; } const ctx = { approvedGitOps: loadApprovedGitOps(sessionId), editedFiles: readEditedFiles(sessionId), pathNormalize, protectedPaths, + commitGrantOpen, now: Date.now(), }; const verdict = classifyBashCommand(command, ctx); diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 18d5cb0..b91bdd8 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -90,6 +90,15 @@ describe('scriptWatcherCheck', () => { import { classifyBashCommand } from './enforce-router-gate.mjs'; +describe('classifyBashCommand commit-грант ctx (D2 wiring)', () => { + it('git commit + ctx.commitGrantOpen:true → allow (ctx прокинут в classifyGitCommand)', () => { + expect(classifyBashCommand('git commit -m x', { commitGrantOpen: true }).result).toBe('allow'); + }); + it('git commit без гранта → block', () => { + expect(classifyBashCommand('git commit -m x', {}).result).toBe('block'); + }); +}); + describe('classifyBashCommand — integration', () => { const now = 3_000_000; diff --git a/tools/escape-grant.mjs b/tools/escape-grant.mjs index 7c6eb37..36f8511 100644 --- a/tools/escape-grant.mjs +++ b/tools/escape-grant.mjs @@ -106,18 +106,19 @@ export function loadFloorEscapes(sessionId, now = Date.now(), { keyImpl = resolv .filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS); } -export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; +export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; // D1: благословлённый деплой +export const COMMIT_GRANT_PREFIX = 'commit:'; // D2: коммит силами агента /** - * D1 (благословлённый ops-runbook): ops-runbook-гранты сессии — БЕЗ верхней (5-мин) границы окна - * (окно = существование опечатанного deploy-плана; enforce-floor проверяет, что план с этим хешем - * ещё запечатан + действует). Нижняя граница времени остаётся (future-ts отбрасываем). Подпись - * key-gated как loadFloorEscapes. Только action с префиксом 'ops-runbook:'. keyImpl/fsImpl/runtimeDir - * инъектируемы для тестов. Это НЕ one-shot грант (покрывает много команд runbook'а — consumed не применяем). + * D1/D2: plan-scoped гранты сессии по ПРЕФИКСУ — БЕЗ верхней (5-мин) границы окна (окно = + * существование опечатанного плана; вызыватель-мост проверяет, что план с этим хешем ещё запечатан). + * Нижняя граница времени остаётся (future-ts отбрасываем). Подпись key-gated как loadFloorEscapes. + * Это НЕ one-shot грант (покрывает много операций плана — consumed не применяем). keyImpl/fsImpl/ + * runtimeDir инъектируемы для тестов. */ -export function loadOpsRunbookGrants(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, 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(OPS_RUNBOOK_PREFIX)); + (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; @@ -126,13 +127,19 @@ export function loadOpsRunbookGrants(sessionId, now = Date.now(), { keyImpl = re .filter((g) => now - g.ts >= 0); // нижняя граница (не future-ts); верхней НЕТ (окно = план) } -/** D1: открыт ли ops-runbook-грант на ЭТОТ plan_id (точное совпадение action='ops-runbook:'). */ -export function opsRunbookGrantOpen(planId, grants) { +/** Открыт ли plan-scoped грант на ЭТОТ plan_id (точное совпадение action=''). */ +export function planScopedGrantOpen(prefix, planId, grants) { if (!planId || !Array.isArray(grants)) return false; - const target = `${OPS_RUNBOOK_PREFIX}${planId}`; + 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`); diff --git a/tools/escape-grant.test.mjs b/tools/escape-grant.test.mjs index 0171c99..0ac11a7 100644 --- a/tools/escape-grant.test.mjs +++ b/tools/escape-grant.test.mjs @@ -1,6 +1,31 @@ import { describe, it, expect } from 'vitest'; import { canonicalAction, escapeGrantOpen, FLOOR_ESCAPE_WINDOW_MS } from './escape-grant.mjs'; import { loadOpsRunbookGrants, opsRunbookGrantOpen, OPS_RUNBOOK_PREFIX } from './escape-grant.mjs'; +import { loadCommitGrants, commitGrantOpen, COMMIT_GRANT_PREFIX } from './escape-grant.mjs'; + +describe('commit грант (D2 — окно = существование плана)', () => { + const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); + it('COMMIT_GRANT_PREFIX = "commit:"', () => { + expect(COMMIT_GRANT_PREFIX).toBe('commit:'); + }); + it('commitGrantOpen: грант на ЭТОТ plan_id → true; чужой → false', () => { + expect(commitGrantOpen('H1', [{ action: 'commit:H1', ts: 1 }])).toBe(true); + expect(commitGrantOpen('H2', [{ action: 'commit:H1', ts: 1 }])).toBe(false); + }); + it('commitGrantOpen: пустой/пустой planId → false', () => { + expect(commitGrantOpen('H', [])).toBe(false); + expect(commitGrantOpen('', [{ action: 'commit:', ts: 1 }])).toBe(false); + }); + it('loadCommitGrants: старше 5 мин НЕ отфильтрован; future-ts отброшен; не-commit игнор', () => { + const old = 1000; const now = old + 10 * 60 * 1000; + const fs1 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: old }]); + expect(loadCommitGrants('S', now, { keyImpl: () => null, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true); + const fs2 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: 5000 }]); + expect(loadCommitGrants('S', 1000, { keyImpl: () => null, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]); + const fs3 = mkFs([{ type: 'floor_escape', action: 'ops-runbook:H1', ts: 1 }]); + expect(loadCommitGrants('S', 2, { keyImpl: () => null, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]); + }); +}); const ID = (s) => s; // normalizeImpl-заглушка для путей diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 759ee53..2710511 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -356,8 +356,11 @@ export function classifyGitCommand(command, ctx = {}) { return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' }; } - // 3. conditional → approve check + // 3. conditional → commit: грант (D2: гейт ПРИСУТСТВИЯ отходит) ИЛИ approve check. + // GIT_HARD_PATTERNS уже отсеяли force-push/--no-verify/-c ВЫШЕ — качество/безопасность не ослаблены; + // commit-грант снимает только «присутствие человека», не качество (criterion/verify-гейты отдельны). if (GIT_CONDITIONAL_SUB.has(sub)) { + if (ctx.commitGrantOpen === true) return { result: 'allow', reason: `git ${sub}: подтверждено commit: (одно согласие владельца на ревью-план)` }; const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now()); if (approved) return { result: 'allow', reason: `git ${sub}: подтверждено approve_git_operation` }; return { result: 'block', reason: `git ${sub} требует AskUser approval (approve_git_operation). Запросите подтверждение и повторите.` }; diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index 793d981..15a48aa 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -145,6 +145,26 @@ describe('isApproved (one-shot + 5-min window)', () => { }); import { classifyGitCommand } from './shell-content-rules.mjs'; +describe('classifyGitCommand commit-грант (D2)', () => { + it('git commit при ctx.commitGrantOpen → allow (гейт присутствия отходит)', () => { + const r = classifyGitCommand('git commit -m x', { commitGrantOpen: true }); + expect(r.result).toBe('allow'); + expect(r.reason).toMatch(/commit:|ревью-план|согласие/i); + }); + it('git push при ctx.commitGrantOpen → allow', () => { + expect(classifyGitCommand('git push gitea main', { commitGrantOpen: true }).result).toBe('allow'); + }); + it('без гранта и без approve → block (default-deny держит)', () => { + expect(classifyGitCommand('git commit -m x', {}).result).toBe('block'); + }); + it('GIT_HARD (force-push) блокирует ДАЖЕ при commitGrantOpen (качество/безопасность)', () => { + expect(classifyGitCommand('git push --force', { commitGrantOpen: true }).result).toBe('block'); + }); + it('--no-verify блокируется при commitGrantOpen', () => { + expect(classifyGitCommand('git commit --no-verify -m x', { commitGrantOpen: true }).result).toBe('block'); + }); +}); + describe('classifyGitCommand — readonly', () => { it.each(['git status', 'git log --oneline', 'git diff HEAD~1', 'git branch --show-current', 'git remote -v'])( 'allows %s',