feat: деплой и коммит — только терминальный грант владельца (consent forgery B3)

ops-runbook:<hash> (деплой) и commit:<hash> (коммит агентом) открываются ТОЛЬКО
терминальным грантом владельца (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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 18:39:48 +03:00
parent f87090702c
commit 29287d73c9
3 changed files with 141 additions and 29 deletions
@@ -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:<hash>` (деплой) и `commit:<hash>` (коммит агентом) открываются ТОЛЬКО терминальным грантом владельца + валидной подписью (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 в одном месте).
+8 -14
View File
@@ -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='<prefix><id>'). */
+41 -15
View File
@@ -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([]);
});
});