From 29287d73c95e7f5cada3778f4ea0748123a7d507 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 18:39:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=86=D0=B0=20(consent=20forgery=20B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ops-runbook: (деплой) и commit: (коммит агентом) открываются ТОЛЬКО терминальным грантом владельца (origin:owner-terminal + валидная подпись, ключ обязателен — fail-closed #KEY), не chat floor_escape — Поза 1. loadPlanScopedGrants переписан как loadTerminalGrants + фильтр по префиксу (origin/подпись/нижняя граница в одном месте, DRY). Мосты blessed-ops/commit-grant не тронуты (читают через те же обёртки). Тесты загрузчиков переписаны под новый контракт. Спека §B/§KEY/§CRIT6. Co-Authored-By: Claude Opus 4.8 --- ...gery-fix-B3-deploy-commit-terminal-plan.md | 92 +++++++++++++++++++ tools/escape-grant.mjs | 22 ++--- tools/escape-grant.test.mjs | 56 ++++++++--- 3 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-consent-forgery-fix-B3-deploy-commit-terminal-plan.md diff --git a/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B3-deploy-commit-terminal-plan.md b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B3-deploy-commit-terminal-plan.md new file mode 100644 index 0000000..da39b06 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B3-deploy-commit-terminal-plan.md @@ -0,0 +1,92 @@ +# Consent-forgery fix — Часть B3: деплой+коммит → терминальный грант Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / test-driven-development. + +**Goal:** Согласия `ops-runbook:` (деплой) и `commit:` (коммит агентом) открываются ТОЛЬКО терминальным грантом владельца + валидной подписью (fail-closed), не chat floor_escape. Поза 1. + +**Architecture:** Оба моста (`blessed-ops`/`commit-grant`) читают через `loadOpsRunbookGrants`/`loadCommitGrants` → `loadPlanScopedGrants`. Переписать `loadPlanScopedGrants` как `loadTerminalGrants(...)` + фильтр по префиксу — терминал-only + fail-closed + нижняя граница наследуются от B1 (DRY). Существующие тесты загрузчиков (chat-записи + keyImpl:null) переписать под новый контракт (origin+sig+key). + +**Tech Stack:** Node ESM, vitest. Тесты — через PowerShell. + +**Спека:** §B/§KEY, §CRIT 6/7. Опирается на B1 `loadTerminalGrants`. + +**Режим:** ШТАТНЫЙ. Коммит — дисциплина handoff. + +--- + +### Task 1: `loadPlanScopedGrants` → терминал-only + fail-closed + +**Files:** +- Modify: `tools/escape-grant.mjs` (тело `loadPlanScopedGrants`, ~строки 119-128) +- Test: `tools/escape-grant.test.mjs` + +- [ ] **Step 1: RED — новые контракт-тесты + переписать старые accept-тесты** + +В `tools/escape-grant.test.mjs`: + +(а) Добавить новый describe (новый контракт): +```js +describe('loadPlanScopedGrants терминал-only + fail-closed (Поза 1 B3)', () => { + const KEY = 'ps-key'; + const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); + const term = (action, ts) => signFloorEscapeRecord({ type: 'floor_escape', action, origin: OWNER_TERMINAL_ORIGIN, ts }, KEY); + it('терминальный подписанный commit-грант + ключ → принят', () => { + expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([term('commit:H1', 100)]), runtimeDir: '/rt' }) + .some((g) => g.action === 'commit:H1')).toBe(true); + }); + it('chat commit-грант без origin → отвергнут (даже подписанный)', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:H1', ts: 100 }, KEY); + expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); + it('терминальный ops-грант без ключа → [] (fail-closed #KEY)', () => { + expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => null, fsImpl: mkFs([term('ops-runbook:H1', 100)]), runtimeDir: '/rt' })).toEqual([]); + }); + it('терминальный origin-грант неподписанный → отвергнут', () => { + const rec = { type: 'floor_escape', action: 'ops-runbook:H1', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }; + expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); +}); +``` + +(б) Переписать существующие accept-тесты под новый контракт (origin+sig+key): +- `it('loadCommitGrants: ... старше 5 мин ...')` — записи через `signFloorEscapeRecord({...,origin:OWNER_TERMINAL_ORIGIN}, KEY)`, `keyImpl: () => KEY`. +- `it('loadOpsRunbookGrants: запись старше 5 мин НЕ отфильтрована ...')` — то же. + (future-ts / не-prefix / non-ops тесты уже ждут `[]` — оставить, но дать KEY+origin, чтобы `[]` шёл от логики окна/префикса, а не от fail-closed.) + +- [ ] **Step 2: RED-прогон** + +PowerShell: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: FAIL на «chat без origin → []» и «без ключа → []» (текущий код их принимает), плюс переписанные accept-тесты пока зелёные. + +- [ ] **Step 3: GREEN — реализация** + +В `tools/escape-grant.mjs` заменить тело `loadPlanScopedGrants` (строки 119-128) на: +```js +export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), opts = {}) { + // Поза 1 (#B): тяжёлые plan-scoped гранты (ops-runbook/commit) — ТОЛЬКО терминальные владельца + // (origin+валидная подпись, ключ обязателен) от loadTerminalGrants; здесь лишь фильтр по префиксу. + // Окно plan-scoped (без верхней 5-мин границы) наследуется: loadTerminalGrants режет лишь future-ts. + return loadTerminalGrants(sessionId, now, opts).filter((g) => typeof g.action === 'string' && g.action.startsWith(prefix)); +} +``` +Удалить прежний JSDoc-блок про key-gated fail-open над функцией (он больше неверен) — заменить на краткий выше. + +- [ ] **Step 4: GREEN-прогон** + +PowerShell: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS все. + +--- + +### Task 2: Полный свод + коммит + +- [ ] **Step 1:** PowerShell: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` → зелёный (4341 + новые). Проверить commit-grant/blessed-ops тесты не сломаны (они инъектируют grants напрямую — не через loadPlanScopedGrants — не должны). +- [ ] **Step 2:** `git add tools/escape-grant.mjs tools/escape-grant.test.mjs docs/superpowers/plans/2026-06-18-consent-forgery-fix-B3-deploy-commit-terminal-plan.md` → receipt → `.git/CB_MSG.txt` → `git commit -F`. + +--- + +## Self-Review + +- **Spec coverage:** §CRIT 6 (тяжёлое — только подписанный терминальный грант, нет ключа → не открыто) → Task 1. ops-runbook+commit оба через loadPlanScopedGrants → один фикс. +- **Регрессия:** commit-grant.test/blessed-ops.test инъектируют grants напрямую (loadGrantsImpl) → не затронуты. Проверить полным сводом. +- **DRY:** loadPlanScopedGrants переиспользует loadTerminalGrants (origin+sig+fail-closed в одном месте). diff --git a/tools/escape-grant.mjs b/tools/escape-grant.mjs index 93eb4f8..3449407 100644 --- a/tools/escape-grant.mjs +++ b/tools/escape-grant.mjs @@ -132,21 +132,15 @@ export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; // D1: благословлё export const COMMIT_GRANT_PREFIX = 'commit:'; // D2: коммит силами агента /** - * D1/D2: plan-scoped гранты сессии по ПРЕФИКСУ — БЕЗ верхней (5-мин) границы окна (окно = - * существование опечатанного плана; вызыватель-мост проверяет, что план с этим хешем ещё запечатан). - * Нижняя граница времени остаётся (future-ts отбрасываем). Подпись key-gated как loadFloorEscapes. - * Это НЕ one-shot грант (покрывает много операций плана — consumed не применяем). keyImpl/fsImpl/ - * runtimeDir инъектируемы для тестов. + * D1/D2: plan-scoped гранты сессии по ПРЕФИКСУ (ops-runbook/commit) — БЕЗ верхней (5-мин) границы + * окна (окно = существование опечатанного плана; вызыватель-мост проверяет печать). Поза 1 (#B): + * это ТЯЖЁЛЫЕ согласия → ТОЛЬКО терминальные гранты владельца (origin:'owner-terminal' + валидная + * подпись, ключ ОБЯЗАТЕЛЕН — fail-closed #KEY). Источник, origin-фильтр, проверку подписи и нижнюю + * границу (future-ts) даёт loadTerminalGrants (B1); здесь — лишь фильтр по префиксу. consumed не + * применяем (грант покрывает много операций плана). keyImpl/fsImpl/runtimeDir инъектируемы. */ -export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { - const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter( - (r) => typeof r.action === 'string' && r.action.startsWith(prefix)); - if (records.length === 0) return []; - let key = null; try { key = keyImpl(); } catch { key = null; } - const verified = key ? records.filter((r) => verifyFloorEscapeRecord(r, key)) : records; - return verified - .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })) - .filter((g) => now - g.ts >= 0); // нижняя граница (не future-ts); верхней НЕТ (окно = план) +export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), opts = {}) { + return loadTerminalGrants(sessionId, now, opts).filter((g) => typeof g.action === 'string' && g.action.startsWith(prefix)); } /** Открыт ли plan-scoped грант на ЭТОТ plan_id (точное совпадение action=''). */ diff --git a/tools/escape-grant.test.mjs b/tools/escape-grant.test.mjs index 5bf72dd..2581904 100644 --- a/tools/escape-grant.test.mjs +++ b/tools/escape-grant.test.mjs @@ -44,14 +44,16 @@ describe('commit грант (D2 — окно = существование пла expect(commitGrantOpen('H', [])).toBe(false); expect(commitGrantOpen('', [{ action: 'commit:', ts: 1 }])).toBe(false); }); - it('loadCommitGrants: старше 5 мин НЕ отфильтрован; future-ts отброшен; не-commit игнор', () => { + it('loadCommitGrants: терминальный старше 5 мин НЕ отфильтрован; future-ts отброшен; не-commit игнор', () => { const old = 1000; const now = old + 10 * 60 * 1000; - const fs1 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: old }]); - expect(loadCommitGrants('S', now, { keyImpl: () => null, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true); - const fs2 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: 5000 }]); - expect(loadCommitGrants('S', 1000, { keyImpl: () => null, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]); - const fs3 = mkFs([{ type: 'floor_escape', action: 'ops-runbook:H1', ts: 1 }]); - expect(loadCommitGrants('S', 2, { keyImpl: () => null, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]); + const KEY = 'ck'; + const term = (action, ts) => signFloorEscapeRecord({ type: 'floor_escape', action, origin: OWNER_TERMINAL_ORIGIN, ts }, KEY); + const fs1 = mkFs([term('commit:H1', old)]); + expect(loadCommitGrants('S', now, { keyImpl: () => KEY, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true); + const fs2 = mkFs([term('commit:H1', 5000)]); + expect(loadCommitGrants('S', 1000, { keyImpl: () => KEY, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]); + const fs3 = mkFs([term('ops-runbook:H1', 1)]); + expect(loadCommitGrants('S', 2, { keyImpl: () => KEY, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]); }); }); @@ -76,19 +78,43 @@ describe('ops-runbook грант (D1 — окно = существование expect(opsRunbookGrantOpen('H', null)).toBe(false); expect(opsRunbookGrantOpen('', [{ action: 'ops-runbook:', ts: 1 }])).toBe(false); }); - it('loadOpsRunbookGrants: запись старше 5 мин НЕ отфильтрована (окно = план)', () => { + 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' }); + const KEY = 'ok'; + const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'ops-runbook:HASH1', origin: OWNER_TERMINAL_ORIGIN, ts: old }, KEY)]); + const grants = loadOpsRunbookGrants('S', now, { keyImpl: () => KEY, 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: обычные (не ops-runbook) терминальные floor_escape игнорирует', () => { + const KEY = 'ok'; + const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'bash:rm -rf x', origin: OWNER_TERMINAL_ORIGIN, ts: 1 }, KEY)]); + expect(loadOpsRunbookGrants('S', 2, { keyImpl: () => KEY, 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([]); + const KEY = 'ok'; + const fs = mkFs([signFloorEscapeRecord({ type: 'floor_escape', action: 'ops-runbook:HASH1', origin: OWNER_TERMINAL_ORIGIN, ts: 5000 }, KEY)]); + expect(loadOpsRunbookGrants('S', 1000, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]); + }); +}); + +describe('loadPlanScopedGrants терминал-only + fail-closed (Поза 1 B3)', () => { + const KEY = 'ps-key'; + const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); + const term = (action, ts) => signFloorEscapeRecord({ type: 'floor_escape', action, origin: OWNER_TERMINAL_ORIGIN, ts }, KEY); + it('терминальный подписанный commit-грант + ключ → принят', () => { + expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([term('commit:H1', 100)]), runtimeDir: '/rt' }) + .some((g) => g.action === 'commit:H1')).toBe(true); + }); + it('chat commit-грант без origin → отвергнут (даже подписанный)', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:H1', ts: 100 }, KEY); + expect(loadCommitGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); + it('терминальный ops-грант без ключа → [] (fail-closed #KEY)', () => { + expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => null, fsImpl: mkFs([term('ops-runbook:H1', 100)]), runtimeDir: '/rt' })).toEqual([]); + }); + it('терминальный origin-грант неподписанный → отвергнут', () => { + const rec = { type: 'floor_escape', action: 'ops-runbook:H1', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }; + expect(loadOpsRunbookGrants('S', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); }); });