feat: D1 — благословлённый ops-runbook (деплой выполняет агент под ревью)

Деплой, помеченный **Kind:** deploy и опечатанный (наставник+судья GO,
judge_mode=live-block), агент выполняет по белому списку шагов под ОДНИМ
согласием владельца `FLOOR-ESCAPE: ops-runbook:<plan-hash>` — без аварийного
выхода на каждую команду. «Ядерный» набор (rm -rf/force-push/migrate:fresh/
db:wipe) остаётся на per-command escape.

- plan-lock: freezePlan принимает kind (в подписанную базу + хеш, как delivery);
  не-'normal' добавляет поле, обычные планы байт-идентичны старым печатям.
- plan-skills: parsePlanKind (**Kind:** deploy|normal, default normal).
- seal-orchestration: sealablePlan/sealPlan прокидывают kind в печать.
- escape-grant: loadOpsRunbookGrants (окно = существование плана, БЕЗ 5-мин
  фильтра) + opsRunbookGrantOpen (точный матч на plan_id).
- floor-decide: floorDecide получает инъектируемый blessedOps(cmd); content-block
  команда из набора пропускается, ЯДЕРНЫЙ набор (bashIsFloor) исключён из послабления.
- blessed-ops (новый модуль-мост): buildBlessedOps + loadBlessedOpsForSession —
  знает план+пол, чтобы СОХРАНИТЬ Δ9 (enforce-floor не зависит от модуля печати плана).
  Предикат пускает команду только дословно из Bash-листов опечатанного deploy-плана.
- enforce-floor: gated — blessed-ops грузит план/гранты ТОЛЬКО при открытом
  ops-runbook-гранте; без согласия владельца пол плана не касается (Δ9 цел).

План: docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md
Спека: docs/superpowers/specs/2026-06-18-blessed-ops-runbook-design.md §3.1-3.7.
+33 теста, свод 4299 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 13:19:22 +03:00
parent bc1d2a370a
commit bbc053e0a6
15 changed files with 919 additions and 10 deletions
@@ -0,0 +1,601 @@
# Благословлённый ops-runbook (D1) — 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:** Деплой, одобренный наставником+судьёй и опечатанный как `kind:"deploy"`-план, выполняет агент по белому списку шагов под ОДНИМ согласием владельца `ops-runbook:<plan-hash>` — без аварийного выхода на каждую команду; «ядерные» команды остаются за per-command escape.
**Architecture:** Чистое ядро пола `floorDecide` остаётся без знания о плане — оно получает инъектируемый предикат `blessedOps(cmd)`. Обёртка `enforce-floor.main()` строит этот предикат ТОЛЬКО когда в сессии открыт `ops-runbook:<hash>`-грант (динамический gated-импорт `plan-lock` — амендмент Δ9: пол по-прежнему первее плана и независим, пока владелец явно не благословил runbook). Грант `ops-runbook:<hash>` пишется тем же `toFloorEscapeRecord` (`FLOOR-ESCAPE: ops-runbook:<hash>`), но грузится отдельным загрузчиком БЕЗ 5-мин окна (окно = существование опечатанного плана).
**Tech Stack:** Node ESM, vitest (через PowerShell-инструмент: `npx vitest run <file> --config vitest.config.tools.mjs --no-file-parallelism`), HMAC-расписки (receipt-sign), действующая машинерия пола/escape/плана.
**Спека:** [docs/superpowers/specs/2026-06-18-blessed-ops-runbook-design.md](../specs/2026-06-18-blessed-ops-runbook-design.md) §3.1-3.7, критерий §5, тест-набросок §6.
**Дисциплина:** строго TDD (RED → видеть падение → GREEN), полный свод через PowerShell после КАЖДОГО GREEN. Правки пола — только в ШТАТНОМ режиме. Коммит — по дисциплине handoff (явные пути + produce-verify-receipt + `.git/CB_MSG.txt`).
**Граница «ядерного» (§3.4):** ops-runbook покрывает `bashIsContentBlock && !bashIsFloor` (composer/npm install, systemctl, scp, artisan down/up, редиректы логов). НЕ покрывает `bashIsFloor` (`classify-destructive`: rm -rf, git push --force, reset --hard, migrate:fresh/reset/refresh, db:wipe) — тот остаётся на per-command escape. `rm -rf` одновременно content-block И floor → НЕ благословляется (floor-флаг исключает).
---
## File Structure
- `tools/plan-lock.mjs``freezePlan` получает `kind` (в подписанную базу + хеш, как `delivery`).
- `tools/plan-skills.mjs``parsePlanKind(content)` (зеркало `parsePlanDelivery`).
- `tools/seal-orchestration.mjs``sealablePlan`/`sealPlan` прокидывают `kind` в `freezePlan`.
- `tools/escape-grant.mjs``loadOpsRunbookGrants(sessionId)` (без 5-мин окна, подпись-verified) + `opsRunbookGrantOpen(planId, grants)`.
- `tools/floor-decide.mjs``floorDecide` получает инъектируемый `blessedOps` предикат; ветка в Bash-content-block.
- `tools/enforce-floor.mjs``main()` строит `blessedOps` из опечатанного плана + ops-runbook-грантов (gated dynamic import `plan-lock`); `decide` прокидывает предикат.
- `docs/Pravila_raboty_Claude_v1_1.md` (или GUIDE) — норматив-заметка §3.5 (читать вывод → стоп на аномалии). Отдельным docs-коммитом, вне этого плана-кода.
---
## Task 1: `freezePlan` принимает `kind` (в подпись + хеш)
**Files:**
- Modify: `tools/plan-lock.mjs` (`freezePlan`)
- Test: `tools/plan-lock.test.mjs`
- [ ] **Step 1: Написать падающий тест**
```javascript
describe('freezePlan kind (D1 — благословлённый ops-runbook)', () => {
const K = 'k-kind';
it('kind:"deploy" попадает в подписанную печать и верифицируется', () => {
const p = freezePlan({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', key: K, nowMs: 1 });
expect(p.kind).toBe('deploy');
expect(verifyFrozenPlan(p, K)).toBe(true);
});
it('kind по умолчанию (normal) НЕ добавляет поле — старые печати байт-идентичны', () => {
const a = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], key: K, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'normal', key: K, nowMs: 1 });
expect('kind' in a).toBe(false);
expect(a.sig).toBe(b.sig);
});
it('подмена kind ломает подпись', () => {
const p = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'deploy', key: K, nowMs: 1 });
expect(verifyFrozenPlan({ ...p, kind: 'normal' }, K)).toBe(false);
});
it('другой kind → другой plan_id (kind в хеше через подписанную базу)', () => {
const a = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'deploy', key: K, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], key: K, nowMs: 1 });
expect(a.sig).not.toBe(b.sig);
});
});
```
- [ ] **Step 2: Прогнать — ожидать RED**
Run: `npx vitest run tools/plan-lock.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (`p.kind` undefined; sig не различается по kind).
- [ ] **Step 3: Реализация — `kind` в подписанную базу**
В `tools/plan-lock.mjs`, `freezePlan` — расширить сигнатуру и базу (зеркало `delivery`):
```javascript
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', kind = 'normal', key, nowMs }) {
assertValidJudgeMode(judgeMode);
const sealedSteps = withCriterionIds(steps);
const id = planId(sealedSteps);
const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
if (delivery && delivery !== 'internal') base.delivery = delivery;
// D1: kind в подписанную базу ТОЛЬКО если не-'normal' — обычные планы байт-идентичны старым печатям.
if (kind && kind !== 'normal') base.kind = kind;
return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}
```
- [ ] **Step 4: Прогнать — ожидать GREEN**
Run: `npx vitest run tools/plan-lock.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 5: Полный свод**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: всё зелёное (база 4266 + новые).
---
## Task 2: `parsePlanKind` + прокидывание в печать плана
**Files:**
- Modify: `tools/plan-skills.mjs` (новый `parsePlanKind`)
- Modify: `tools/seal-orchestration.mjs` (`sealablePlan`/`sealPlan`)
- Test: `tools/plan-skills.test.mjs`, `tools/seal-orchestration.test.mjs`
- [ ] **Step 1: Написать падающий тест (parsePlanKind)**
В `tools/plan-skills.test.mjs`:
```javascript
import { parsePlanKind } from './plan-skills.mjs';
describe('parsePlanKind (D1)', () => {
it('**Kind:** deploy → "deploy"', () => {
expect(parsePlanKind('# План\n**Kind:** deploy\n')).toBe('deploy');
});
it('нет пометки / мусор → "normal" (fail-safe)', () => {
expect(parsePlanKind('# План без пометки')).toBe('normal');
expect(parsePlanKind('**Kind:** wat')).toBe('normal');
});
});
```
- [ ] **Step 2: Прогнать — RED**
Run: `npx vitest run tools/plan-skills.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (`parsePlanKind` не определён).
- [ ] **Step 3: Реализация parsePlanKind**
В `tools/plan-skills.mjs` (зеркало `parsePlanDelivery`):
```javascript
/** Пометка типа плана: `**Kind:** deploy`. По умолчанию/мусор → 'normal' (fail-safe:
* благословлённый ops-канал применяется ТОЛЬКО к явному deploy-плану). Зеркало parsePlanDelivery. */
export function parsePlanKind(content) {
const m = String(content ?? '').match(/(^|\n)\*\*Kind:\*\*\s*(deploy|normal)\b/i);
return m ? m[2].toLowerCase() : 'normal';
}
```
- [ ] **Step 4: Прогнать — GREEN (parsePlanKind)**
Run: `npx vitest run tools/plan-skills.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 5: Падающий тест прокидывания в sealPlan**
В `tools/seal-orchestration.test.mjs` (рядом с существующими sealPlan-тестами; verdict-GO/key/artifact взять из соседних — см. файл; ниже минимальный каркас, подставить локальные хелперы файла):
```javascript
import { sealablePlan } from './seal-orchestration.mjs';
describe('sealablePlan несёт kind (D1)', () => {
it('план с **Kind:** deploy → sealablePlan().kind === "deploy"', () => {
const md = '# План\n**Kind:** deploy\n```steps-json\n[{"op":"Bash","object":"composer install","ref":"r"}]\n```';
expect(sealablePlan(md).kind).toBe('deploy');
});
it('обычный план → kind "normal"', () => {
const md = '```steps-json\n[{"op":"Bash","object":"x","ref":"r"}]\n```';
expect(sealablePlan(md).kind).toBe('normal');
});
});
```
- [ ] **Step 6: Прогнать — RED**
Run: `npx vitest run tools/seal-orchestration.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (`sealablePlan().kind` undefined).
- [ ] **Step 7: Реализация прокидывания**
В `tools/seal-orchestration.mjs`:
```javascript
import { parsePlanSkills, parsePlanDelivery, parsePlanKind } from './plan-skills.mjs';
// ...
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md), kind: parsePlanKind(md) }; }
```
И в `sealPlan(...)` прокинуть kind в freezeImpl:
```javascript
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, kind: planObj.kind, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
```
(NB: `judgedHashOf(planObj)` теперь хеширует объект С `kind` — сверка judged_hash остаётся консистентной, т.к. судья хешировал тот же sealablePlan. Проверить existing sealPlan judged_hash-тесты в Step 8.)
- [ ] **Step 8: Прогнать — GREEN + полный свод**
Run: `npx vitest run tools/seal-orchestration.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Затем: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: всё зелёное. Если judged_hash-тест sealPlan покраснел — значит judged_hash считался над объектом без kind; убедиться, что и судья, и печать используют один `sealablePlan` (так и есть — обе ветки зовут `sealablePlan`/`judgedHashOf(planObj)`).
---
## Task 3: `loadOpsRunbookGrants` + `opsRunbookGrantOpen` (грант без 5-мин окна)
**Files:**
- Modify: `tools/escape-grant.mjs`
- Test: `tools/escape-grant.test.mjs`
- [ ] **Step 1: Написать падающий тест**
```javascript
import { loadOpsRunbookGrants, opsRunbookGrantOpen, OPS_RUNBOOK_PREFIX } from './escape-grant.mjs';
describe('ops-runbook грант (D1 — окно = существование плана, не 5 мин)', () => {
const sess = 'S-ops';
// мем-fs с одной floor_escape-записью ops-runbook (подпись пропустим: keyImpl→null → принять все)
const mkFs = (records) => ({
existsSync: () => true,
readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n'),
});
it('opsRunbookGrantOpen: грант на ЭТОТ plan_id → true', () => {
const grants = [{ action: 'ops-runbook:HASH1', ts: 1 }];
expect(opsRunbookGrantOpen('HASH1', grants)).toBe(true);
});
it('opsRunbookGrantOpen: грант на ЧУЖОЙ хеш → false', () => {
expect(opsRunbookGrantOpen('HASH2', [{ action: 'ops-runbook:HASH1', ts: 1 }])).toBe(false);
});
it('opsRunbookGrantOpen: пустой/не-массив → false', () => {
expect(opsRunbookGrantOpen('H', [])).toBe(false);
expect(opsRunbookGrantOpen('H', null)).toBe(false);
expect(opsRunbookGrantOpen('', [{ action: 'ops-runbook:', ts: 1 }])).toBe(false);
});
it('loadOpsRunbookGrants: запись старше 5 мин НЕ отфильтрована (окно = план, не время)', () => {
const old = 1000; const now = old + 10 * 60 * 1000; // +10 мин
const fs = mkFs([{ type: 'floor_escape', action: 'ops-runbook:HASH1', ts: old }]);
const grants = loadOpsRunbookGrants(sess, now, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' });
expect(grants.some((g) => g.action === 'ops-runbook:HASH1')).toBe(true);
});
it('loadOpsRunbookGrants: обычные (не ops-runbook) floor_escape игнорирует', () => {
const fs = mkFs([{ type: 'floor_escape', action: 'bash:rm -rf x', ts: 1 }]);
const grants = loadOpsRunbookGrants(sess, 2, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' });
expect(grants).toEqual([]);
});
});
```
- [ ] **Step 2: Прогнать — RED**
Run: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (функций нет).
- [ ] **Step 3: Реализация**
В `tools/escape-grant.mjs` добавить (использует уже-приватный `readFloorEscapeRecordsAt` — вынести его экспорт ИЛИ продублировать чтение; ниже — переиспользование через внутренний reader, делаем reader доступным внутри модуля):
```javascript
export const OPS_RUNBOOK_PREFIX = 'ops-runbook:';
/**
* D1: ops-runbook-гранты сессии — БЕЗ 5-мин окна (окно = существование опечатанного плана;
* enforce-floor проверяет, что план с этим хешем ещё запечатан). Подпись key-gated как
* loadFloorEscapes. Только action с префиксом 'ops-runbook:'. keyImpl/fsImpl/runtimeDir инъектируемы.
*/
export function loadOpsRunbookGrants(sessionId, 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));
if (records.length === 0) return [];
let key = null; try { key = keyImpl(); } catch { key = null; }
const verified = key ? records.filter((r) => verifyFloorEscapeRecord(r, key)) : records;
// Нижняя граница времени (не future-ts), но БЕЗ верхней (окно = план):
return verified
.map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 }))
.filter((g) => now - g.ts >= 0);
}
/** D1: открыт ли ops-runbook-грант на ЭТОТ plan_id (точное совпадение action='ops-runbook:<id>'). */
export function opsRunbookGrantOpen(planId, grants) {
if (!planId || !Array.isArray(grants)) return false;
const target = `${OPS_RUNBOOK_PREFIX}${planId}`;
return grants.some((g) => g && g.action === target);
}
```
(NB: `readFloorEscapeRecordsAt` сейчас приватна. Если её не видно — поднять до module-scope reuse: она уже module-scope `function`, доступна внутри файла. Экспорт не требуется.)
- [ ] **Step 4: Прогнать — GREEN + полный свод**
Run: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Затем полный свод.
Expected: всё зелёное.
---
## Task 4: `floorDecide` — инъектируемый `blessedOps` предикат (ветка ops-runbook)
**Files:**
- Modify: `tools/floor-decide.mjs` (`floorDecide`)
- Test: `tools/floor-decide.test.mjs`
- [ ] **Step 1: Написать падающий тест**
```javascript
describe('floorDecide blessedOps (D1 — благословлённый ops-шаг)', () => {
const blessed = (allowed) => (cmd) => allowed.includes(cmd);
it('content-block команда (composer install) + blessedOps→true → block:false', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'composer install' } }, blessedOps: blessed(['composer install']) });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/ops-runbook|благословл/i);
});
it('та же команда без blessedOps → block:true (как сейчас)', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'composer install' } } });
expect(r.block).toBe(true);
});
it('blessedOps НЕ распространяется на ЯДЕРНУЮ команду (rm -rf) даже если предикат true', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'rm -rf build' } }, blessedOps: () => true });
expect(r.block).toBe(true); // floor-nuclear исключён из благословения
});
it('blessedOps НЕ распространяется на force-push (floor, не content-block)', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force origin main' } }, blessedOps: () => true });
expect(r.block).toBe(true);
});
it('команда не из набора (blessedOps→false) → block:true', () => {
const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'composer install' } }, blessedOps: blessed(['npm install']) });
expect(r.block).toBe(true);
});
});
```
- [ ] **Step 2: Прогнать — RED**
Run: `npx vitest run tools/floor-decide.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (blessedOps игнорируется; composer install блокируется).
- [ ] **Step 3: Реализация — ветка в Bash-content-block**
В `tools/floor-decide.mjs`, сигнатура `floorDecide` + Bash-ветка:
```javascript
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize, blessedOps = null }) {
// ... (без изменений до name === 'Bash') ...
if (name === 'Bash') {
const cmd = input.command || '';
const nuclear = bashIsFloor(cmd);
if (bashIsContentBlock(cmd)) {
// D1: благословлённый ops-шаг runbook — content-block (НЕ ядерный) команда дословно из
// опечатанного deploy-плана под открытым ops-runbook:<hash>. Один грант на весь runbook.
// Ядерный набор (bashIsFloor) ИСКЛЮЧён — rm -rf/force-push остаются на per-command escape.
if (!nuclear && typeof blessedOps === 'function' && blessedOps(cmd)) {
return { block: false, reason: 'floor: благословлённый ops-шаг runbook (ops-runbook:<hash>) — пропуск под согласием владельца' };
}
if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' };
return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` };
}
if (nuclear) {
if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом владельца (floor_escape)' };
return { block: true, reason: `floor: необратимая команда без аварийного выхода — блок (вето-до-плана); FLOOR-ESCAPE: ${action}` };
}
return { block: false, reason: 'floor: Bash не необратимо' };
}
// ... остальное без изменений ...
}
```
(Замена: прежние `if (bashIsContentBlock(...))` и `if (bashIsFloor(...))` слиты в один блок с предвычисленным `nuclear`, чтобы благословление точно НЕ касалось ядерного. Поведение для всех существующих случаев идентично, добавлен только `!nuclear && blessedOps`-проход.)
- [ ] **Step 4: Прогнать — GREEN + полный свод**
Run: `npx vitest run tools/floor-decide.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Затем полный свод.
Expected: всё зелёное (существующие floor-тесты держат прежнее поведение).
---
## Task 5: `enforce-floor` — построение `blessedOps` из плана + грантов (gated import)
**Files:**
- Modify: `tools/enforce-floor.mjs` (`decide` прокидывает `blessedOps`; `main()` строит его)
- Test: `tools/enforce-floor.test.mjs`
- [ ] **Step 1: Написать падающий тест (decide прокидывает blessedOps в floorDecide)**
```javascript
describe('enforce-floor decide прокидывает blessedOps (D1)', () => {
it('blessedOps доходит до floorDecideImpl', () => {
let seen = null;
const spy = (args) => { seen = args.blessedOps; return { block: false, reason: 'stub' }; };
const bless = (c) => c === 'composer install';
decide({ event: { tool_name: 'Bash', tool_input: { command: 'composer install' } }, blessedOps: bless, floorDecideImpl: spy });
expect(seen).toBe(bless);
});
it('без blessedOps — floorDecideImpl получает undefined (обратная совместимость)', () => {
let had = 'sentinel';
const spy = (args) => { had = ('blessedOps' in args) ? args.blessedOps : 'absent'; return { block: false }; };
decide({ event: { tool_name: 'Bash', tool_input: { command: 'ls' } }, floorDecideImpl: spy });
expect(had === undefined || had === 'absent').toBe(true);
});
});
```
- [ ] **Step 2: Прогнать — RED**
Run: `npx vitest run tools/enforce-floor.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (`decide` не принимает/не прокидывает blessedOps).
- [ ] **Step 3: Реализация — `decide` прокидывает blessedOps**
В `tools/enforce-floor.mjs`, `decide`:
```javascript
export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl, floorDecideImpl = floorDecide, blessedOps = null }) {
const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} };
const args = { toolUse, escapeGrants, escapeConsumed, now };
if (normalizeImpl) args.normalizeImpl = normalizeImpl;
if (blessedOps) args.blessedOps = blessedOps;
try {
return floorDecideImpl(args);
} catch {
if (escapeAllowsEvent(event, escapeGrants, escapeConsumed, now)) {
return { block: false, reason: 'floor: panic-escape (floorDecide бросил, escape владельца чтится)' };
}
return { block: true, reason: 'floor: внутренняя ошибка вычисления — fail-CLOSED' };
}
}
```
- [ ] **Step 4: Прогнать — GREEN (decide)**
Run: `npx vitest run tools/enforce-floor.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 5: Падающий тест — построитель blessedOps (чистая функция, тестируемо без I/O)**
Вынести построение в экспортируемую чистую функцию `buildBlessedOps`:
```javascript
import { buildBlessedOps } from './enforce-floor.mjs';
describe('buildBlessedOps (D1 — белый список из опечатанного deploy-плана)', () => {
const K = 'k';
const norm = (p) => String(p);
it('грант на хеш + kind:deploy + valid seal → предикат пускает Bash-шаг дословно', () => {
// план собираем реальной freezePlan, чтобы plan_id/sig совпали
const plan = freezePlanFixture({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'live-block', key: K });
const grants = [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }];
const bless = buildBlessedOps({ frozenPlan: plan, grants, key: K, verifyImpl: () => true, normalize: norm });
expect(typeof bless).toBe('function');
expect(bless('composer install')).toBe(true);
expect(bless('rm -rf /')).toBe(false); // не шаг плана
});
it('нет гранта на этот хеш → предикат null (благословления нет)', () => {
const plan = freezePlanFixture({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'live-block', key: K });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: 'ops-runbook:OTHER', ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
it('план не kind:deploy → null', () => {
const plan = freezePlanFixture({ steps: [{ op: 'Bash', object: 'composer install' }], judgeMode: 'live-block', key: K });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
it('печать невалидна (verifyImpl→false) → null', () => {
const plan = freezePlanFixture({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'live-block', key: K });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => false, normalize: norm })).toBe(null);
});
it('judge_mode не live-block → null (нет одобрения к энфорсменту)', () => {
const plan = freezePlanFixture({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'shadow', key: K });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
});
```
(В шапке теста: `import { freezePlan as freezePlanFixture } from './plan-lock.mjs';` — реальная печать, чтобы plan_id совпал с грантом.)
- [ ] **Step 6: Прогнать — RED**
Run: `npx vitest run tools/enforce-floor.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL (`buildBlessedOps` не определён).
- [ ] **Step 7: Реализация buildBlessedOps (импорт plan-lock допустим в этом файле — gated по факту наличия гранта вызывающим)**
В `tools/enforce-floor.mjs` добавить импорты и функцию:
```javascript
import { verifyFrozenPlan as verifyFrozenPlanDefault, actionMatchesStep, treeLeaves } from './plan-lock.mjs';
import { opsRunbookGrantOpen } from './escape-grant.mjs';
/**
* D1: построить предикат blessedOps(cmd) ИЗ опечатанного deploy-плана под открытым
* ops-runbook-грантом. null (благословления нет), если: нет гранта на этот plan_id /
* план не kind:"deploy" / печать невалидна / judge_mode≠live-block. Предикат пускает
* команду ТОЛЬКО если она дословно совпадает с Bash-листом плана (белый список, §3.3).
* Чистая (frozenPlan/grants/key/verifyImpl/normalize инъектируемы) — I/O делает main().
*/
export function buildBlessedOps({ frozenPlan, grants, key, verifyImpl = verifyFrozenPlanDefault, normalize } = {}) {
if (!frozenPlan || !frozenPlan.plan_id) return null;
if (!opsRunbookGrantOpen(frozenPlan.plan_id, grants)) return null;
if (frozenPlan.kind !== 'deploy') return null;
if (frozenPlan.judge_mode !== 'live-block') return null; // одобрение к энфорсменту (SE-2-зеркало)
if (!verifyImpl(frozenPlan, key)) return null; // печать цела
const bashLeaves = treeLeaves(frozenPlan.steps || []).filter((s) => s && String(s.op) === 'Bash');
if (bashLeaves.length === 0) return null;
return (cmd) => bashLeaves.some((s) => actionMatchesStep(s, { op: 'Bash', object: cmd }, normalize ? { normalize } : {}));
}
```
- [ ] **Step 8: Прогнать — GREEN (buildBlessedOps)**
Run: `npx vitest run tools/enforce-floor.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 9: Врезать в `main()` (gated загрузка плана/грантов)**
В `tools/enforce-floor.mjs`, `main()` — после `escapeGrants/escapeConsumed`, ПЕРЕД `decide`:
```javascript
async function main() {
try {
const event = parseEventJson(await readStdin());
const sess = (event && event.session_id) || 'unknown';
const escapeGrants = loadFloorEscapes(sess);
const escapeConsumed = loadConsumed(sess);
// D1: благословлённый ops-runbook — gated. Грузим план/ops-гранты ТОЛЬКО если есть
// ops-runbook-грант (Δ9 сохранён: без явного согласия владельца пол плана НЕ касается).
let blessedOps = null;
try {
const { loadOpsRunbookGrants } = await import('./escape-grant.mjs');
const opsGrants = loadOpsRunbookGrants(sess);
if (opsGrants.length > 0) {
const os = await import('node:os'); const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
const { loadFrozenPlan } = await import('./plan-lock.mjs');
const runtimeDir = `${os.homedir()}/.claude/runtime`;
const key = resolveReceiptKey();
const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir });
blessedOps = buildBlessedOps({ frozenPlan, grants: opsGrants, key });
}
} catch { blessedOps = null; } // сбой → благословления нет (fail-CLOSED к послаблению)
const r = decide({ event, escapeGrants, escapeConsumed, blessedOps });
if (r.block) logGuardBlock(event, 'М5 Пол', r.reason);
exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined });
} catch {
exitDecision({ block: true, message: '[floor] внутренняя ошибка — fail-CLOSED' });
}
}
```
(`buildBlessedOps` default `normalize` = undefined → `actionMatchesStep` берёт `pathNormalize`; для Bash матч идёт по `normCommand`, путь не нужен — корректно.)
- [ ] **Step 10: Прогнать — полный свод**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: всё зелёное.
---
## Task 6: Интеграционный тест критерия §5 (сквозной floor-проход)
**Files:**
- Test: `tools/enforce-floor.test.mjs` (новый describe)
- [ ] **Step 1: Написать тест сквозного критерия**
```javascript
import { freezePlan } from './plan-lock.mjs';
describe('D1 критерий §5 — сквозной floor-проход благословлённого runbook', () => {
const K = 'k-e2e';
const plan = freezePlan({ steps: [{ op: 'Bash', object: 'composer install --no-dev' }, { op: 'Bash', object: 'rm -rf storage/cache' }], kind: 'deploy', judgeMode: 'live-block', key: K });
const bless = buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true });
it('content-block ops-шаг плана (composer) → floor пускает', () => {
expect(decide({ event: { tool_name: 'Bash', tool_input: { command: 'composer install --no-dev' } }, blessedOps: bless }).block).toBe(false);
});
it('ЯДЕРНЫЙ шаг того же плана (rm -rf) → floor блокирует (нужен per-command escape)', () => {
expect(decide({ event: { tool_name: 'Bash', tool_input: { command: 'rm -rf storage/cache' } }, blessedOps: bless }).block).toBe(true);
});
it('команда НЕ из плана (composer update) → floor блокирует (белый список)', () => {
expect(decide({ event: { tool_name: 'Bash', tool_input: { command: 'composer update' } }, blessedOps: bless }).block).toBe(true);
});
});
```
- [ ] **Step 2: Прогнать — ожидать GREEN сразу (всё реализовано в Task 1-5)**
Run: `npx vitest run tools/enforce-floor.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS (это характеризующий критерий-тест; код уже есть).
- [ ] **Step 3: Финальный полный свод + коммит**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Затем коммит по дисциплине handoff (явные пути; `node tools/produce-verify-receipt.mjs` через PowerShell → signed GREEN; сообщение в `.git/CB_MSG.txt`; `git commit -F`).
---
## Хвост (вне этого плана-кода)
- **Норматив-заметка §3.5** (читать вывод деплоя → стоп на аномалии) — в Pravila/GUIDE через claude-md-management, отдельным docs-коммитом, когда стена вернётся.
- **D2** (коммит силами агента) — отдельная спека `2026-06-18-agent-commit-channel-design.md`, отдельный план.
---
## Self-Review
**1. Покрытие спеки §5:**
- ✅ Один `ops-runbook:<hash>` → агент гонит content-block ops без per-command escape (Task 4+5+6).
- ✅ «Ядерная» команда внутри плана → per-command escape (Task 4 `!nuclear`, Task 6).
- ✅ Команда не из плана → блок (Task 5 белый список, Task 6).
- ✅ Грант на чужой хеш / нет GO → блок (Task 5 `opsRunbookGrantOpen`/`judge_mode`/`verifyImpl`).
- ⚠️ «Агент читает вывод шага, стоп на аномалии» (§5 п.5) — это ДИСЦИПЛИНА исполнения + норматив-заметка (§3.5), не код-гейт. Вынесено в хвост.
- ✅ Указатель не виснет на floor-шаге (пол пропустил → стена матчит шаг штатно; F-J не трогаем).
**2. Плейсхолдеры:** код во всех code-шагах конкретный. Тест-каркасы Task 2 Step 5 / Task 5 ссылаются на локальные хелперы соответствующих тест-файлов (`freezePlan`-фикстура импортируется явно) — при исполнении свериться с шапкой файла.
**3. Согласованность типов:** `blessedOps: (cmd:string)=>bool` — единое имя в floor-decide (Task 4), enforce-floor.decide (Task 5 Step 3), buildBlessedOps return (Task 5 Step 7). `kind` ('deploy'|'normal') — freezePlan (Task 1), parsePlanKind (Task 2), buildBlessedOps (Task 5). `OPS_RUNBOOK_PREFIX`/`opsRunbookGrantOpen`/`loadOpsRunbookGrants` (Task 3) — используются в Task 5.
**4. Риск:** правки `floor-decide` (Bash-ветка слита) — поведение для существующих кейсов идентично (предвычислен `nuclear`, добавлен только `!nuclear && blessedOps`-проход). Полный свод после Task 4 ловит регресс.
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* blessed-ops (D1, спека 2026-06-18-blessed-ops-runbook-design §3.3) — МОСТ план↔пол для
* «благословлённого ops-runbook». Вынесен из enforce-floor отдельным модулем, чтобы СОХРАНИТЬ
* Δ9 (пол первее плана: enforce-floor/floor-decide не зависят от plan-lock). Этот мост знает
* оба слоя, но зовётся ТОЛЬКО когда владелец открыл ops-runbook-грант — без согласия владельца
* пол плана не касается, независимость пола сохранена.
*
* Предикат blessedOps(cmd) пускает Bash-команду ТОЛЬКО если выполнены ВСЕ условия §3.3:
* — открыт ops-runbook:<plan-hash> грант на ЭТОТ план;
* — план kind:"deploy", печать валидна, judge_mode='live-block' (одобрение к энфорсменту);
* — команда ДОСЛОВНО совпадает с Bash-листом опечатанного плана (белый список).
* «Ядерный» набор (bashIsFloor) НЕ благословляется здесь — его отсекает floor-decide (§3.4).
*/
import { homedir } from 'node:os';
import { verifyFrozenPlan, actionMatchesStep, treeLeaves, loadFrozenPlan } from './plan-lock.mjs';
import { opsRunbookGrantOpen, loadOpsRunbookGrants } from './escape-grant.mjs';
import { resolveReceiptKey } from './receipt-key-config.mjs';
/**
* Чистое ядро: построить предикат blessedOps(cmd) из опечатанного deploy-плана под открытым
* ops-runbook-грантом. null (благословления нет), если: нет плана / нет гранта на этот plan_id /
* план не kind:"deploy" / judge_mode≠live-block / печать невалидна / нет Bash-листов. Всё инъектируемо.
*/
export function buildBlessedOps({ frozenPlan, grants, key, verifyImpl = verifyFrozenPlan, normalize } = {}) {
if (!frozenPlan || !frozenPlan.plan_id) return null;
if (!opsRunbookGrantOpen(frozenPlan.plan_id, grants)) return null;
if (frozenPlan.kind !== 'deploy') return null;
if (frozenPlan.judge_mode !== 'live-block') return null; // одобрение к энфорсменту (зеркало SE-2 стены)
if (!verifyImpl(frozenPlan, key)) return null; // печать цела
const bashLeaves = treeLeaves(frozenPlan.steps || []).filter((s) => s && String(s.op) === 'Bash');
if (bashLeaves.length === 0) return null;
const opts = normalize ? { normalize } : {};
return (cmd) => bashLeaves.some((s) => actionMatchesStep(s, { op: 'Bash', object: cmd }, opts));
}
/**
* I/O (gated): построить blessedOps для сессии. Грузит ops-runbook-гранты ПЕРВЫМ; нет грантов →
* null БЕЗ загрузки плана/ключа (Δ9: общий путь пола плана не касается). Есть грант → грузим
* опечатанный план + ключ и строим предикат. fsImpl/keyImpl/runtimeDir/loadPlanImpl инъектируемы.
*/
export function loadBlessedOpsForSession(sessionId, {
loadGrantsImpl = loadOpsRunbookGrants,
loadPlanImpl = loadFrozenPlan,
keyImpl = resolveReceiptKey,
runtimeDir = `${homedir()}/.claude/runtime`,
} = {}) {
const grants = loadGrantsImpl(sessionId);
if (!Array.isArray(grants) || grants.length === 0) return null;
const frozenPlan = loadPlanImpl({ sessionId, runtimeDir });
const key = keyImpl();
return buildBlessedOps({ frozenPlan, grants, key });
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { buildBlessedOps, loadBlessedOpsForSession } from './blessed-ops.mjs';
import { freezePlan } from './plan-lock.mjs';
describe('buildBlessedOps (D1 — белый список из опечатанного deploy-плана)', () => {
const K = 'k-bless';
const norm = (p) => String(p);
const mk = (over) => freezePlan({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'live-block', key: K, nowMs: 1, ...over });
it('грант на хеш + kind:deploy + live-block + valid seal → предикат пускает Bash-шаг дословно', () => {
const plan = mk();
const bless = buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true, normalize: norm });
expect(typeof bless).toBe('function');
expect(bless('composer install')).toBe(true);
expect(bless('rm -rf /')).toBe(false);
});
it('нет гранта на этот хеш → null', () => {
const plan = mk();
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: 'ops-runbook:OTHER', ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
it('план не kind:deploy → null', () => {
const plan = freezePlan({ steps: [{ op: 'Bash', object: 'composer install' }], judgeMode: 'live-block', key: K, nowMs: 1 });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
it('печать невалидна (verifyImpl→false) → null', () => {
const plan = mk();
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => false, normalize: norm })).toBe(null);
});
it('judge_mode не live-block → null', () => {
const plan = mk({ judgeMode: 'shadow' });
expect(buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true, normalize: norm })).toBe(null);
});
it('нет frozenPlan → null', () => {
expect(buildBlessedOps({ frozenPlan: null, grants: [{ action: 'ops-runbook:x', ts: 1 }], key: K, verifyImpl: () => true })).toBe(null);
});
});
describe('loadBlessedOpsForSession (D1 — gated I/O: нет гранта → план не грузим)', () => {
it('нет ops-runbook-грантов → null, план НЕ грузится (Δ9 сохранён)', () => {
let planLoaded = false;
const r = loadBlessedOpsForSession('S', {
loadGrantsImpl: () => [],
loadPlanImpl: () => { planLoaded = true; return null; },
keyImpl: () => 'k',
});
expect(r).toBe(null);
expect(planLoaded).toBe(false);
});
it('есть грант на хеш + deploy-план → предикат построен', () => {
const key = 'k-load';
const plan = freezePlan({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', judgeMode: 'live-block', key, nowMs: 1 });
const bless = loadBlessedOpsForSession('S', {
loadGrantsImpl: () => [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }],
loadPlanImpl: () => plan,
keyImpl: () => key,
});
expect(typeof bless).toBe('function');
expect(bless('composer install')).toBe(true);
});
});
+12 -2
View File
@@ -14,15 +14,21 @@ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.
import { floorDecide } from './floor-decide.mjs';
import { loadFloorEscapes, loadConsumed, escapeAllowsEvent } from './escape-grant.mjs';
import { logGuardBlock } from './guard-block-log.mjs';
// D1 (благословлённый ops-runbook): мост план↔пол вынесен в отдельный модуль blessed-ops, чтобы
// СОХРАНИТЬ Δ9 (обёртка пола не зависит от модуля печати плана напрямую — см. шапку выше).
// loadBlessedOpsForSession зовётся в main ТОЛЬКО когда владелец открыл ops-runbook-грант —
// без согласия владельца пол плана НЕ касается.
import { loadBlessedOpsForSession } from './blessed-ops.mjs';
/** Чистое решение: делегирует floor-decide. escapeGrants/escapeConsumed/now/normalizeImpl инъектируемы.
* M7 Фаза 2 (правило 7б): floorDecide обёрнут в try — если он бросит ДО своего escape-чека,
* panic-ветка всё равно оценивает escape владельца (иначе баг = кирпич мимо escape).
* floorDecideImpl инъектируем для теста panic-пути. */
export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl, floorDecideImpl = floorDecide }) {
export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl, floorDecideImpl = floorDecide, blessedOps = null }) {
const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} };
const args = { toolUse, escapeGrants, escapeConsumed, now };
if (normalizeImpl) args.normalizeImpl = normalizeImpl;
if (blessedOps) args.blessedOps = blessedOps; // D1: благословлённый ops-runbook предикат
try {
return floorDecideImpl(args);
} catch {
@@ -39,7 +45,11 @@ async function main() {
const sess = (event && event.session_id) || 'unknown';
const escapeGrants = loadFloorEscapes(sess); // read-only, window-filtered
const escapeConsumed = loadConsumed(sess); // отметки one-shot погашения
const r = decide({ event, escapeGrants, escapeConsumed });
// D1: благословлённый ops-runbook — GATED. blessed-ops грузит план/ops-гранты ТОЛЬКО если
// есть открытый ops-runbook-грант (Δ9 сохранён: без согласия владельца пол плана НЕ касается).
let blessedOps = null;
try { blessedOps = loadBlessedOpsForSession(sess); } catch { blessedOps = null; } // сбой → нет послабления (fail-CLOSED)
const r = decide({ event, escapeGrants, escapeConsumed, blessedOps });
if (r.block) logGuardBlock(event, 'М5 Пол', r.reason);
exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined });
} catch {
+34
View File
@@ -3,10 +3,44 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { decide } from './enforce-floor.mjs';
import { buildBlessedOps } from './blessed-ops.mjs';
import { freezePlan } from './plan-lock.mjs';
// enforce-floor — тонкая обёртка floor-decide. decide() чистая (approvedGitOps инъект).
const ev = (tool_name, tool_input) => ({ tool_name, tool_input, session_id: 's1' });
describe('enforce-floor.decide прокидывает blessedOps в floorDecideImpl (D1)', () => {
it('blessedOps доходит до floorDecideImpl', () => {
let seen = 'sentinel';
const spy = (args) => { seen = args.blessedOps; return { block: false, reason: 'stub' }; };
const bless = (c) => c === 'composer install';
decide({ event: ev('Bash', { command: 'composer install' }), blessedOps: bless, floorDecideImpl: spy });
expect(seen).toBe(bless);
});
it('без blessedOps — floorDecideImpl не получает поле (обратная совместимость)', () => {
let had = 'sentinel';
const spy = (args) => { had = ('blessedOps' in args) ? args.blessedOps : 'absent'; return { block: false }; };
decide({ event: ev('Bash', { command: 'ls' }), floorDecideImpl: spy });
expect(had).toBe('absent');
});
});
// D1 критерий §5 — сквозной floor-проход благословлённого runbook (decide + предикат из blessed-ops).
describe('D1 критерий §5 — сквозной floor-проход благословлённого runbook', () => {
const K = 'k-e2e';
const plan = freezePlan({ steps: [{ op: 'Bash', object: 'composer install --no-dev' }, { op: 'Bash', object: 'rm -rf storage/cache' }], kind: 'deploy', judgeMode: 'live-block', key: K, nowMs: 1 });
const bless = buildBlessedOps({ frozenPlan: plan, grants: [{ action: `ops-runbook:${plan.plan_id}`, ts: 1 }], key: K, verifyImpl: () => true });
it('content-block ops-шаг плана (composer) → floor пускает', () => {
expect(decide({ event: ev('Bash', { command: 'composer install --no-dev' }), blessedOps: bless }).block).toBe(false);
});
it('ЯДЕРНЫЙ шаг того же плана (rm -rf) → floor блокирует (нужен per-command escape)', () => {
expect(decide({ event: ev('Bash', { command: 'rm -rf storage/cache' }), blessedOps: bless }).block).toBe(true);
});
it('команда НЕ из плана (composer update) → floor блокирует (белый список)', () => {
expect(decide({ event: ev('Bash', { command: 'composer update' }), blessedOps: bless }).block).toBe(true);
});
});
describe('enforce-floor.decide — делегирует floor-decide', () => {
it('необратимая Bash без одобрения → block', () => {
const r = decide({ event: ev('Bash', { command: 'php artisan migrate:fresh' }), approvedGitOps: [] });
+27
View File
@@ -106,6 +106,33 @@ 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:';
/**
* D1 (благословлённый ops-runbook): ops-runbook-гранты сессии — БЕЗ верхней (5-мин) границы окна
* (окно = существование опечатанного deploy-плана; enforce-floor проверяет, что план с этим хешем
* ещё запечатан + действует). Нижняя граница времени остаётся (future-ts отбрасываем). Подпись
* key-gated как loadFloorEscapes. Только action с префиксом 'ops-runbook:'. keyImpl/fsImpl/runtimeDir
* инъектируемы для тестов. Это НЕ one-shot грант (покрывает много команд runbook'а — consumed не применяем).
*/
export function loadOpsRunbookGrants(sessionId, 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));
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); // нижняя граница (не future-ts); верхней НЕТ (окно = план)
}
/** D1: открыт ли ops-runbook-грант на ЭТОТ plan_id (точное совпадение action='ops-runbook:<id>'). */
export function opsRunbookGrantOpen(planId, grants) {
if (!planId || !Array.isArray(grants)) return false;
const target = `${OPS_RUNBOOK_PREFIX}${planId}`;
return grants.some((g) => g && g.action === target);
}
/** I/O: отметки-погашения. */
export function loadConsumed(sessionId) {
const path = join(homedir(), '.claude', 'runtime', `floor-escape-consumed-${sessionId || 'unknown'}.jsonl`);
+36
View File
@@ -1,8 +1,44 @@
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';
const ID = (s) => s; // normalizeImpl-заглушка для путей
describe('ops-runbook грант (D1 — окно = существование плана, не 5 мин)', () => {
const mkFs = (records) => ({
existsSync: () => true,
readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n'),
});
it('OPS_RUNBOOK_PREFIX = "ops-runbook:"', () => {
expect(OPS_RUNBOOK_PREFIX).toBe('ops-runbook:');
});
it('opsRunbookGrantOpen: грант на ЭТОТ plan_id → true', () => {
expect(opsRunbookGrantOpen('HASH1', [{ action: 'ops-runbook:HASH1', ts: 1 }])).toBe(true);
});
it('opsRunbookGrantOpen: грант на ЧУЖОЙ хеш → false', () => {
expect(opsRunbookGrantOpen('HASH2', [{ action: 'ops-runbook:HASH1', ts: 1 }])).toBe(false);
});
it('opsRunbookGrantOpen: пустой/не-массив/пустой planId → false', () => {
expect(opsRunbookGrantOpen('H', [])).toBe(false);
expect(opsRunbookGrantOpen('H', null)).toBe(false);
expect(opsRunbookGrantOpen('', [{ action: 'ops-runbook:', ts: 1 }])).toBe(false);
});
it('loadOpsRunbookGrants: запись старше 5 мин НЕ отфильтрована (окно = план)', () => {
const old = 1000; const now = old + 10 * 60 * 1000;
const fs = mkFs([{ type: 'floor_escape', action: 'ops-runbook:HASH1', ts: old }]);
const grants = loadOpsRunbookGrants('S', now, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' });
expect(grants.some((g) => g.action === 'ops-runbook:HASH1')).toBe(true);
});
it('loadOpsRunbookGrants: обычные (не ops-runbook) floor_escape игнорирует', () => {
const fs = mkFs([{ type: 'floor_escape', action: 'bash:rm -rf x', ts: 1 }]);
expect(loadOpsRunbookGrants('S', 2, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]);
});
it('loadOpsRunbookGrants: future-ts (ts > now) отброшен (нижняя граница времени)', () => {
const fs = mkFs([{ type: 'floor_escape', action: 'ops-runbook:HASH1', ts: 5000 }]);
expect(loadOpsRunbookGrants('S', 1000, { keyImpl: () => null, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]);
});
});
describe('escape-grant canonicalAction', () => {
it('Bash → bash:<normalized command>', () => {
expect(canonicalAction('Bash', { command: 'git push --force' }, { normalizeImpl: ID }))
+12 -3
View File
@@ -123,7 +123,7 @@ export function bashIsContentBlock(command) {
* @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism)
* @returns {{block:boolean, reason:string}}
*/
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize }) {
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize, blessedOps = null }) {
if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' };
const name = toolUse.name;
const input = toolUse.input || {};
@@ -131,12 +131,21 @@ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], n
const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now);
if (name === 'Bash') {
const cmd = input.command || '';
const nuclear = bashIsFloor(cmd); // classify-destructive floor-набор (rm -rf/force-push/migrate:fresh/db:wipe)
// M7 Task 1.3 (правило 8, V1): content-block по СОДЕРЖАНИЮ — независимо от плана, escapable.
if (bashIsContentBlock(input.command || '')) {
if (bashIsContentBlock(cmd)) {
// D1 (благословлённый ops-runbook §3.3-3.4): content-block (НЕ ядерная) команда дословно из
// опечатанного deploy-плана под открытым ops-runbook:<hash> → пропуск (ОДИН грант на весь
// runbook, не на каждую команду). Ядерный набор (bashIsFloor) ИСКЛЮЧЁН — rm -rf/force-push
// остаются на per-command escape; `rm -rf` (одновременно content-block И floor) не благословляется.
if (!nuclear && typeof blessedOps === 'function' && blessedOps(cmd)) {
return { block: false, reason: 'floor: благословлённый ops-шаг runbook (ops-runbook:<hash>) — пропуск под согласием владельца' };
}
if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' };
return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` };
}
if (bashIsFloor(input.command || '')) {
if (nuclear) {
if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом владельца (floor_escape)' };
return { block: true, reason: `floor: необратимая команда без аварийного выхода — блок (вето-до-плана); FLOOR-ESCAPE: ${action}` };
}
+21
View File
@@ -9,6 +9,27 @@ const id = (s) => s; // identity normalize для детерминизма path-
const bash = (command) => ({ name: 'Bash', input: { command } });
const write = (file_path) => ({ name: 'Write', input: { file_path } });
describe('floorDecide blessedOps (D1 — благословлённый ops-шаг runbook)', () => {
const blessed = (allowed) => (cmd) => allowed.includes(cmd);
it('content-block команда (composer install) + blessedOps→true → block:false', () => {
const r = floorDecide({ toolUse: bash('composer install'), blessedOps: blessed(['composer install']) });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/ops-runbook|благословл/i);
});
it('та же команда без blessedOps → block:true (прежнее поведение)', () => {
expect(floorDecide({ toolUse: bash('composer install') }).block).toBe(true);
});
it('blessedOps НЕ распространяется на ЯДЕРНУЮ rm -rf (даже если предикат true)', () => {
expect(floorDecide({ toolUse: bash('rm -rf build'), blessedOps: () => true }).block).toBe(true);
});
it('blessedOps НЕ распространяется на force-push (floor, не content-block)', () => {
expect(floorDecide({ toolUse: bash('git push --force origin main'), blessedOps: () => true }).block).toBe(true);
});
it('команда не из набора (blessedOps→false) → block:true', () => {
expect(floorDecide({ toolUse: bash('composer install'), blessedOps: blessed(['npm install']) }).block).toBe(true);
});
});
describe('floorDecide — вето на необратимое (независимо от плана)', () => {
const BLOCK_BASH = [
'git push --force',
+5 -1
View File
@@ -59,7 +59,7 @@ export function assertValidJudgeMode(mode) {
/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
* artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта).
* 5.1: каждый шаг получает детерминированный criterion_id ДО planId/печати → id запечатан. */
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', key, nowMs }) {
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', kind = 'normal', key, nowMs }) {
assertValidJudgeMode(judgeMode);
const sealedSteps = withCriterionIds(steps);
const id = planId(sealedSteps);
@@ -69,6 +69,10 @@ export function freezePlan({ steps, skills = [], artifactId = null, judgeMode =
// E-S1 Фаза 1: delivery в подписанную базу ТОЛЬКО если не-'internal' — internal-планы
// (умолчание) остаются байт-идентичны старым печатям (обратная совместимость подписи).
if (delivery && delivery !== 'internal') base.delivery = delivery;
// D1 (благословлённый ops-runbook): kind в подписанную базу ТОЛЬКО если не-'normal' — обычные
// планы байт-идентичны старым печатям. Подмена kind ломает подпись; благословлённый ops-канал
// (enforce-floor) применяется только к kind:'deploy'.
if (kind && kind !== 'normal') base.kind = kind;
return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}
+24
View File
@@ -23,6 +23,30 @@ describe('freezePlan delivery', () => {
});
});
describe('freezePlan kind (D1 — благословлённый ops-runbook)', () => {
const K = 'k-kind';
it('kind:"deploy" попадает в подписанную печать и верифицируется', () => {
const p = freezePlan({ steps: [{ op: 'Bash', object: 'composer install' }], kind: 'deploy', key: K, nowMs: 1 });
expect(p.kind).toBe('deploy');
expect(verifyFrozenPlan(p, K)).toBe(true);
});
it('kind по умолчанию (normal) НЕ добавляет поле — старые печати байт-идентичны', () => {
const a = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], key: K, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'normal', key: K, nowMs: 1 });
expect('kind' in a).toBe(false);
expect(a.sig).toBe(b.sig);
});
it('подмена kind ломает подпись', () => {
const p = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'deploy', key: K, nowMs: 1 });
expect(verifyFrozenPlan({ ...p, kind: 'normal' }, K)).toBe(false);
});
it('другой kind → другой sig (kind в подписанной базе)', () => {
const a = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], kind: 'deploy', key: K, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Bash', object: 'x' }], key: K, nowMs: 1 });
expect(a.sig).not.toBe(b.sig);
});
});
describe('removeFrozenPlan (Фаза 5 — чистое завершение: стена снимает печать)', () => {
const fsWithUnlink = () => {
const s = new Map();
+8
View File
@@ -26,3 +26,11 @@ export function parsePlanDelivery(content) {
const m = String(content ?? '').match(/(^|\n)\*\*Delivery:\*\*\s*(internal|user-result)\b/i);
return m ? m[2].toLowerCase() : 'internal';
}
/** D1: пометка типа плана: `**Kind:** deploy`. По умолчанию/мусор → 'normal' (fail-safe:
* благословлённый ops-runbook-канал enforce-floor применяется ТОЛЬКО к явному deploy-плану).
* Зеркало parsePlanDelivery. */
export function parsePlanKind(content) {
const m = String(content ?? '').match(/(^|\n)\*\*Kind:\*\*\s*(deploy|normal)\b/i);
return m ? m[2].toLowerCase() : 'normal';
}
+11 -1
View File
@@ -1,6 +1,16 @@
// tools/plan-skills.test.mjs
import { describe, it, expect } from 'vitest';
import { parsePlanSkills, extractPlanGoal, parsePlanDelivery } from './plan-skills.mjs';
import { parsePlanSkills, extractPlanGoal, parsePlanDelivery, parsePlanKind } from './plan-skills.mjs';
describe('parsePlanKind (D1)', () => {
it('**Kind:** deploy → "deploy"', () => {
expect(parsePlanKind('# План\n**Kind:** deploy\n')).toBe('deploy');
});
it('нет пометки / мусор → "normal" (fail-safe)', () => {
expect(parsePlanKind('# План без пометки')).toBe('normal');
expect(parsePlanKind('**Kind:** wat')).toBe('normal');
});
});
describe('parsePlanDelivery', () => {
it('читает user-result из маркера', () => {
+3 -3
View File
@@ -12,12 +12,12 @@
*/
import { buildArtifact } from './artifact-from-spec.mjs';
import { parsePlanSteps } from './plan-steps-parse.mjs';
import { parsePlanSkills, parsePlanDelivery } from './plan-skills.mjs';
import { parsePlanSkills, parsePlanDelivery, parsePlanKind } from './plan-skills.mjs';
import { contentHash, sealOnApproval, requiresOwnerSeal } from './judge-seal-channel.mjs';
import { freezeArtifact, freezePlan, planId } from './plan-lock.mjs';
export function sealableArtifact(md) { return buildArtifact(md); } // {sections, source_sha}
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md) }; } // {steps,skills,delivery}
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md), kind: parsePlanKind(md) }; } // {steps,skills,delivery,kind}
export function judgedHashOf(obj) { return contentHash(obj); }
function isRealGo(v) { return !!(v && v.wired === true && v.decision === 'GO'); }
@@ -59,7 +59,7 @@ export function sealPlan({ md, currentArtifact, verdict, key, judgeMode, nowMs,
if (d.via === 'wired-go' && verdict.judged_hash !== judgedHashOf(planObj)) {
return { sealed: false, reason: 'judged_hash mismatch (SD-1/TOCTOU)' };
}
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, kind: planObj.kind, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
return { sealed: true, seal, via: d.via };
}
+13
View File
@@ -41,6 +41,19 @@ describe('seal-orchestration', () => {
const r = sealPlan({ md: planMd, currentArtifact: null, verdict: goVerdict(planObj), key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(false);
});
// D1: тип плана прокидывается из markdown в печать.
const deployMd = '# План\n**Kind:** deploy\n```steps-json\n[{"op":"Bash","object":"composer install","ref":"dec-a"}]\n```';
it('sealablePlan несёт kind: deploy-план → "deploy", обычный → "normal"', () => {
expect(sealablePlan(deployMd).kind).toBe('deploy');
expect(sealablePlan(planMd).kind).toBe('normal');
});
it('sealPlan печатает kind:"deploy" в опечатанный план', () => {
const planObj = sealablePlan(deployMd);
const r = sealPlan({ md: deployMd, currentArtifact: { artifact_id: 'AID' }, verdict: goVerdict(planObj), key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(true);
expect(r.seal.kind).toBe('deploy');
});
});
describe('owner-seal (SP3)', () => {