feat: D2 — канал коммита под ревью (агент коммитит под commit:<hash>)

Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно
согласие владельца `FLOOR-ESCAPE: commit:<plan-hash>` → агент делает git
add/commit/push без терминала владельца. Гейт ПРИСУТСТВИЯ (router-gate
git-approval) отходит; гейты КАЧЕСТВА (criterion-gate/verify-gate) НЕ тронуты —
код-коммит всё равно требует по-критерийный GREEN и свежую расписку. Согласия
деплоя (ops-runbook:) и коммита (commit:) — раздельные кнопки.

- escape-grant: обобщён plan-scoped загрузчик (loadPlanScopedGrants/
  planScopedGrantOpen, окно = существование плана); D1 ops-runbook стал тонкой
  обёрткой; добавлены commit: COMMIT_GRANT_PREFIX/loadCommitGrants/commitGrantOpen.
- commit-grant (новый мост план↔router-gate): commitGrantOpenForSession —
  открыт ли commit:<hash> на валидный sealed live-block план сессии.
- shell-content-rules classifyGitCommand: conditional-git пускается при
  ctx.commitGrantOpen; GIT_HARD (force-push/--no-verify/-c) блокирует ПЕРВЫМ
  (качество/безопасность не ослаблены).
- enforce-router-gate: main кладёт ctx.commitGrantOpen (gated через мост).

План: docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md
Спека: docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md §3.1-3.2.

ОТЛОЖЕНО (требует решения владельца, в хвосте плана):
- §3.3 docs/ops без criterion/verify: .md уже пропускается; расширение на
  не-.md ops-артефакты конфликтует с CLAUDE.md §13 v2.40 — нужен явный список.
- §3.4 десинк push-последним-шагом: рискованная правка снятия печати стены.

+22 теста, свод 4319 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 13:58:05 +03:00
parent bbc053e0a6
commit b47a71c66b
9 changed files with 501 additions and 12 deletions
@@ -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:<plan-hash>` → агент делает git add/commit/push без терминала владельца; гейты КАЧЕСТВА (criterion/verify) остаются нетронуты, гейт ПРИСУТСТВИЯ (router-gate git-approval) отходит.
**Architecture:** Зеркало D1. `commit:<hash>` грант грузится загрузчиком БЕЗ 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 <file> --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:<hash>`; согласия деплоя и коммита — РАЗДЕЛЬНЫЕ кнопки (`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 = '<prefix><planId>'). */
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:<plan-hash> грант на ВАЛИДНЫЙ
* опечатанный ревью-план сессии (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:<hash> грант (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:<hash> (одно согласие на ревью-план)` };
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:<hash> → 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:<hash>:** `toFloorEscapeRecord` уже захватывает любую `FLOOR-ESCAPE: <action>``commit:<hash>` пишется без правок парсера (как ops-runbook). Подтверждено чтением кода — отдельной задачи не нужно.
- **Норматив/GUIDE:** «коммит силами агента под commit:<hash>» — в Pravila/GUIDE через claude-md-management, когда стена вернётся.
---
## Self-Review
**1. Покрытие §5 (закрытая владельцем сердцевина):**
- ✅ Опечатанный план + commit:<hash> → агент git commit/push (Task 2+3+4).
- ✅ Без commit:<hash> → 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-тесты держат регресс).
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* commit-grant (D2, спека 2026-06-18-agent-commit-channel-design §3.1) — МОСТ план↔router-gate
* для «канала коммита под ревью». Вычисляет: открыт ли commit:<plan-hash> грант на ВАЛИДНЫЙ
* опечатанный ревью-план сессии (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;
}
+53
View File
@@ -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:<hash> → 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);
});
});
+6
View File
@@ -192,11 +192,17 @@ async function main() {
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
// D2 (канал коммита под ревью): открыт ли commit:<plan-hash> на валидный 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);
+9
View File
@@ -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;
+18 -11
View File
@@ -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:<id>'). */
export function opsRunbookGrantOpen(planId, grants) {
/** Открыт ли plan-scoped грант на ЭТОТ plan_id (точное совпадение action='<prefix><id>'). */
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`);
+25
View File
@@ -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-заглушка для путей
+4 -1
View File
@@ -356,8 +356,11 @@ export function classifyGitCommand(command, ctx = {}) {
return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' };
}
// 3. conditional → approve check
// 3. conditional → commit:<hash> грант (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:<hash> (одно согласие владельца на ревью-план)` };
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). Запросите подтверждение и повторите.` };
+20
View File
@@ -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',