From bbc053e0a663403874d63f751ce7758b4d1de3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 18 Jun 2026 13:19:22 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20D1=20=E2=80=94=20=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D0=B3=D0=BE=D1=81=D0=BB=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20ops-runbook=20(=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=20=D0=BF=D0=BE=D0=B4=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Деплой, помеченный **Kind:** deploy и опечатанный (наставник+судья GO, judge_mode=live-block), агент выполняет по белому списку шагов под ОДНИМ согласием владельца `FLOOR-ESCAPE: ops-runbook:` — без аварийного выхода на каждую команду. «Ядерный» набор (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 --- .../2026-06-18-blessed-ops-runbook-plan.md | 601 ++++++++++++++++++ tools/blessed-ops.mjs | 53 ++ tools/blessed-ops.test.mjs | 59 ++ tools/enforce-floor.mjs | 14 +- tools/enforce-floor.test.mjs | 34 + tools/escape-grant.mjs | 27 + tools/escape-grant.test.mjs | 36 ++ tools/floor-decide.mjs | 15 +- tools/floor-decide.test.mjs | 21 + tools/plan-lock.mjs | 6 +- tools/plan-lock.test.mjs | 24 + tools/plan-skills.mjs | 8 + tools/plan-skills.test.mjs | 12 +- tools/seal-orchestration.mjs | 6 +- tools/seal-orchestration.test.mjs | 13 + 15 files changed, 919 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md create mode 100644 tools/blessed-ops.mjs create mode 100644 tools/blessed-ops.test.mjs diff --git a/docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md b/docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md new file mode 100644 index 0000000..fa59807 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-blessed-ops-runbook-plan.md @@ -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:` — без аварийного выхода на каждую команду; «ядерные» команды остаются за per-command escape. + +**Architecture:** Чистое ядро пола `floorDecide` остаётся без знания о плане — оно получает инъектируемый предикат `blessedOps(cmd)`. Обёртка `enforce-floor.main()` строит этот предикат ТОЛЬКО когда в сессии открыт `ops-runbook:`-грант (динамический gated-импорт `plan-lock` — амендмент Δ9: пол по-прежнему первее плана и независим, пока владелец явно не благословил runbook). Грант `ops-runbook:` пишется тем же `toFloorEscapeRecord` (`FLOOR-ESCAPE: ops-runbook:`), но грузится отдельным загрузчиком БЕЗ 5-мин окна (окно = существование опечатанного плана). + +**Tech Stack:** Node ESM, vitest (через PowerShell-инструмент: `npx vitest run --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:'). */ +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:. Один грант на весь 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:) — пропуск под согласием владельца' }; + } + 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:` → агент гонит 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 ловит регресс. diff --git a/tools/blessed-ops.mjs b/tools/blessed-ops.mjs new file mode 100644 index 0000000..0c39a08 --- /dev/null +++ b/tools/blessed-ops.mjs @@ -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: грант на ЭТОТ план; + * — план 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 }); +} diff --git a/tools/blessed-ops.test.mjs b/tools/blessed-ops.test.mjs new file mode 100644 index 0000000..b529514 --- /dev/null +++ b/tools/blessed-ops.test.mjs @@ -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); + }); +}); diff --git a/tools/enforce-floor.mjs b/tools/enforce-floor.mjs index 7820f50..7bf3a58 100644 --- a/tools/enforce-floor.mjs +++ b/tools/enforce-floor.mjs @@ -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 { diff --git a/tools/enforce-floor.test.mjs b/tools/enforce-floor.test.mjs index d8bfadb..e96dbff 100644 --- a/tools/enforce-floor.test.mjs +++ b/tools/enforce-floor.test.mjs @@ -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: [] }); diff --git a/tools/escape-grant.mjs b/tools/escape-grant.mjs index 36f5440..7c6eb37 100644 --- a/tools/escape-grant.mjs +++ b/tools/escape-grant.mjs @@ -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:'). */ +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`); diff --git a/tools/escape-grant.test.mjs b/tools/escape-grant.test.mjs index 12db5d4..0171c99 100644 --- a/tools/escape-grant.test.mjs +++ b/tools/escape-grant.test.mjs @@ -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:', () => { expect(canonicalAction('Bash', { command: 'git push --force' }, { normalizeImpl: ID })) diff --git a/tools/floor-decide.mjs b/tools/floor-decide.mjs index c22aad6..7067ada 100644 --- a/tools/floor-decide.mjs +++ b/tools/floor-decide.mjs @@ -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: → пропуск (ОДИН грант на весь + // 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:) — пропуск под согласием владельца' }; + } 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}` }; } diff --git a/tools/floor-decide.test.mjs b/tools/floor-decide.test.mjs index 0bdc982..703763c 100644 --- a/tools/floor-decide.test.mjs +++ b/tools/floor-decide.test.mjs @@ -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', diff --git a/tools/plan-lock.mjs b/tools/plan-lock.mjs index 30d8807..9851d80 100644 --- a/tools/plan-lock.mjs +++ b/tools/plan-lock.mjs @@ -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) }; } diff --git a/tools/plan-lock.test.mjs b/tools/plan-lock.test.mjs index ab7a0f3..b94c604 100644 --- a/tools/plan-lock.test.mjs +++ b/tools/plan-lock.test.mjs @@ -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(); diff --git a/tools/plan-skills.mjs b/tools/plan-skills.mjs index e5bdf6d..ff875b1 100644 --- a/tools/plan-skills.mjs +++ b/tools/plan-skills.mjs @@ -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'; +} diff --git a/tools/plan-skills.test.mjs b/tools/plan-skills.test.mjs index 50930ce..a8b7baf 100644 --- a/tools/plan-skills.test.mjs +++ b/tools/plan-skills.test.mjs @@ -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 из маркера', () => { diff --git a/tools/seal-orchestration.mjs b/tools/seal-orchestration.mjs index c3f736f..ffc7219 100644 --- a/tools/seal-orchestration.mjs +++ b/tools/seal-orchestration.mjs @@ -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 }; } diff --git a/tools/seal-orchestration.test.mjs b/tools/seal-orchestration.test.mjs index d50c382..70915a1 100644 --- a/tools/seal-orchestration.test.mjs +++ b/tools/seal-orchestration.test.mjs @@ -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)', () => {