diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 7e7a52c2..b5fd1668 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,14 +1,14 @@ # Brain Status (auto-generated) -Last updated: 2026-06-10T00:48:13.135Z +Last updated: 2026-06-10T01:28:55.386Z | Контролёр | Состояние | Детали | |---|---|---| | C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift | | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | -| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 1 week(s) ago | +| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 2 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ✅ | 795 episode(s) this month · Stop-hook + post-commit OK | +| C5 Observer-coverage | ✅ | 798 episode(s) this month · Stop-hook + post-commit OK | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync | ## Кто на посту (оборона М1–М6) @@ -37,9 +37,9 @@ Last updated: 2026-06-10T00:48:13.135Z ## Метрики (информационные, не алерты) -- Observer evidence: 795 episodes this month, 0 observer_error markers, 0 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 795 -- Last /brain-retro: 13 day(s) ago +- Observer evidence: 798 episodes this month, 0 observer_error markers, 0 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 798 +- Last /brain-retro: 14 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -53,9 +53,9 @@ Baseline дисциплины роутера (этап 2 router discipline overh | feature | 27 | 11.1% | 3.7% | | bugfix | 27 | 14.8% | 18.5% | -Router step distribution: 1: 380, 2: 294, 3: 18, 5: 87 +Router step distribution: 1: 382, 2: 295, 3: 18, 5: 87 -Boundaries applied (ADR / границы): 8 of 779 эпизодов (1.0%). +Boundaries applied (ADR / границы): 8 of 782 эпизодов (1.0%). ## Активные многоэтапные проекты @@ -73,10 +73,10 @@ Boundaries applied (ADR / границы): 8 of 779 эпизодов (1.0%). | Компонент | Токены (in/out) | USD | |---|---|---| -| Classifier (Sonnet 4.6) | 49056/204257 | $3.21 | +| Classifier (Sonnet 4.6) | 49095/204651 | $3.22 | | Self-assessment (Sonnet 4.6) | 0/0 | $0.00 | | Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 | -| **Итого** | | **$3.21** | +| **Итого** | | **$3.22** | ## Аномалии классификатора @@ -89,7 +89,7 @@ Episodes since last run: 542 / threshold: 10 ## Reviewer: субагент vs fallback -0 эпизодов проверено из 795. +0 эпизодов проверено из 798. ## Reviewer findings @@ -115,9 +115,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3916 | MsMpEng | 3.51ч | 12284760.8ч | -| 1208 | svchost | 1.42ч | 0.0ч | -| 4 | System | 1.14ч | 0.0ч | +| 3916 | MsMpEng | 3.58ч | 0.0ч | +| 1208 | svchost | 1.44ч | 0.0ч | +| 4 | System | 1.16ч | 98.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/2026-06-10-phase8-state-snapshot.md b/docs/superpowers/2026-06-10-phase8-state-snapshot.md new file mode 100644 index 00000000..185b6036 --- /dev/null +++ b/docs/superpowers/2026-06-10-phase8-state-snapshot.md @@ -0,0 +1,40 @@ +# Снимок состояния — М7 Фаза 8 (код-предусловие флипа собрано) + +**Дата:** 2026-06-10 · **Кодовая фраза эпика:** «роутер-наставник» · **Ветка:** `main` (HEAD `4dd2098e`) +**Назначение:** зафиксировать «где стоим» перед тест-гейтом §9.2 и флипом. Источник истины — код в `main` + спека М7 `2026-06-08-router-mentor-machine-7-design.md` + аудит `2026-06-09-phase8-readiness-audit.md`. Память — фон, не SoT. + +--- + +## Где стоим одной строкой +**Весь Claude-собираемый код готов и в `main`.** Осталось: (A) активация судьи владельцем, (B) «большой переезд» Фазы 8 (регистрация М1–М6 + увольнение зоопарка — `settings.json`, только владелец), (C) тест-гейт §9.2 как предусловие, (D) разблокировка GitHub. Невыполненной кодовой работы у Claude по эпику нет. + +## ✅ Готово (верифицировано по `main` — 11 хук-файлов машин/ворот на месте) +| Блок | Статус | +|---|---| +| М1 фундамент (журнал hash-chain + ключ + расписки) | ✅ здоров (аудит Pass B) | +| М2 стена (default-deny вне печати, tree-волны R-08) | ✅ | +| М3 роутер-классификатор (по журналу K2) | ✅ (живой Sonnet = A7 владельца) | +| М4 судья (пол + движок, линзы gate1/2/3, транспорт `callAnthropicAPI`) | ✅ код; активация — владелец | +| М5 пол (content-floor Bash+PS; блокёр №1 закрыт) | ✅ полный | +| М6 escape + снимок (откат, one-shot грант) | ✅ здоров | +| G1 verify-gate (подписанный GREEN перед commit/push) | ✅ построен | +| Level B criterion-gate (по-критерийный mutation-proven) | ✅ `enforce-criterion-gate.mjs` | +| Связка печати (sealed-plan production) | ✅ `5fd4031b`+`09598dd5` | +| Гейт-1 (судейство спек) + SE-2 (стена не чтит shadow-печать) | ✅ `0289e93c`+`ec73a5c2` | +| judge_mode seal-time guard (defense-in-depth) | ✅ `4dd2098e` | +| Доска «кто на посту» + манифест 11 хуков | ✅ (live-детализация escape/блоков — отложена) | + +## 🟥 Осталось — РАБОТА (шаги владельца, Claude'у закрыто) +**A. Активация судьи М4:** keychain (`router-mentor-judge` + `router-mentor-receipts`) → завершить ИИ-проводку `runJudgeGate` (seam отдаёт нейтральный GO до владельца) → `ROUTER_MENTOR_JUDGE_ENABLED=1` → обкатка `shadow`→`block`. +**B. Фаза 8 переезд (`settings.json`):** атомарно зарегистрировать 13 хуков М1–М6 (пол #1 ДО снятия `router-gate` — порядок критичен) → снять v4-зоопарк (~20 хуков §10) → 3 ENV-флага → `sealedPlanCoversEdit` live + matcher на дисциплинарные исходники. +**C. Тест-гейт §9.2 (предусловие B):** регрессия tools-only ≥ планки + per-machine smokes (каждая М1–М6 рубит свой класс, fail-CLOSE, громко) + интеграционные (escape сквозной / манифест кричит при снятом страже / ЗАКОН требует ключ, КАРТА — нет / дисциплина fail-CLOSE+громко / журнал-факт не обходится текстом). +**D. GitHub:** аккаунт `CoralMinister` SUSPENDED → push/fetch 403; `main` ушёл от origin; force-push запрещён (сотрёт чужие коммиты). + +## ⚪ Отложено осознанно (не блокирует флип) +M4 think-layer (4 пункта) · M6 FIX-5 подпись floor_escape-гранта (spec §6 «не обязательно») · доска recentEscapes/Blocks live-источник (`[]`) · observer Фаза 2 (авто-правка нормативки) · R-10 G6-адаптер · doc-drift D-1 (устаревшая шапка `enforce-judge-gate`). + +## Порядок зависимостей +C (тест-гейт §9.2) → A (активация судьи) → B (переезд) → shadow→block. D (GitHub) — параллельно, нужно только для push. + +## Регрессия на момент снимка +tools-only **3449 passed / 2 skipped / 0 failed** (планка §9.2 ≥3350+2skip перекрыта). diff --git a/docs/superpowers/plans/2026-06-10-floor-escape-signing.md b/docs/superpowers/plans/2026-06-10-floor-escape-signing.md new file mode 100644 index 00000000..696dc4f6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-floor-escape-signing.md @@ -0,0 +1,296 @@ +# Подпись escape-гранта (M6 FIX-5, key-gated) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (инлайн; субагенты запрещены). Per-task: audit-context → TDD → systematic-debugging(на красный) → verification. Гейт закрытия: audit-context → sharp-edges → variant-analysis → regression(tools-only ≥3465+2skip+новые) → verification. Steps — `- [ ]`. + +**Goal:** Подписывать escape-пропуск `{type:'floor_escape', action, ts}` при наличии ключа (writer) и key-gated проверять подпись при чтении (reader) — чтобы форж пропуска без секретного ключа отвергался. Defense-in-depth поверх content-floor. + +**Architecture:** Подпись у писателя (`enforce-askuser-answer-parser.processEvent`, при наличии `resolveReceiptKey()`), проверка у единственного ВЫДАЮЩЕГО читателя (`escape-grant.loadFloorEscapes`, key-gated). Домен `FLOOR_ESCAPE` (R-31). Helpers зеркалят существующие `signApprovalRecord`/`verifyApprovalRecord`. Спека: `docs/superpowers/specs/2026-06-10-floor-escape-signing-design.md`. + +**Tech Stack:** Node ESM, vitest (tools-config). Без новых зависимостей. «Ключ есть» = truthy (`''`/`null` → нет ключа → принять все). + +**Несущий инвариант:** подписываемое == хранимое-минус-`sig` (ровно `{type, action, ts}`); `canonicalJson` сортирует ключи. Энфорсмент авто-включается при провижининге ключа (Фаза 8); до того поведение неизменно. + +--- + +## File Structure +- **Modify:** [tools/receipt-sign.mjs](../../../tools/receipt-sign.mjs) — +домен `FLOOR_ESCAPE`. +- **Modify:** [tools/askuser-answer-parser.mjs](../../../tools/askuser-answer-parser.mjs) — +`signFloorEscapeRecord`/`verifyFloorEscapeRecord` (зеркало `signApprovalRecord`/`verifyApprovalRecord`). +- **Modify:** [tools/enforce-askuser-answer-parser.mjs](../../../tools/enforce-askuser-answer-parser.mjs) — `processEvent` подписывает `esc` при наличии ключа (инъекция `keyImpl`). +- **Modify:** [tools/escape-grant.mjs](../../../tools/escape-grant.mjs) — `loadFloorEscapes` key-gated verify (инъекция `keyImpl`); `loadRecords`→full-record reader. +- **Create:** 4 dedicated тест-файла (имена содержат basename → tdd-gate): `receipt-sign-floor-escape.test.mjs`, `askuser-answer-parser-floor-escape.test.mjs`, `enforce-askuser-answer-parser-floor-escape.test.mjs`, `escape-grant-floor-escape.test.mjs`. + +--- + +## Task 1: домен FLOOR_ESCAPE + sign/verify helpers (TDD) + +**Files:** Modify `receipt-sign.mjs` + `askuser-answer-parser.mjs`; Create 2 test files. + +- [ ] **Step 1: RED — `tools/receipt-sign-floor-escape.test.mjs` (Write)** +```javascript +// tools/receipt-sign-floor-escape.test.mjs +import { describe, it, expect } from 'vitest'; +import { RECEIPT_DOMAINS } from './receipt-sign.mjs'; +describe('RECEIPT_DOMAINS.FLOOR_ESCAPE', () => { + it('равен floor-escape и отличается от соседей (R-31)', () => { + expect(RECEIPT_DOMAINS.FLOOR_ESCAPE).toBe('floor-escape'); + expect(RECEIPT_DOMAINS.FLOOR_ESCAPE).not.toBe(RECEIPT_DOMAINS.APPROVAL); + }); +}); +``` + +- [ ] **Step 2: RED — `tools/askuser-answer-parser-floor-escape.test.mjs` (Write)** +```javascript +// tools/askuser-answer-parser-floor-escape.test.mjs +import { describe, it, expect } from 'vitest'; +import { signFloorEscapeRecord, verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { signApprovalRecord } from './askuser-answer-parser.mjs'; + +const KEY = 'test-receipt-key'; +const REC = { type: 'floor_escape', action: 'bash:git push --force', ts: 1000 }; + +describe('signFloorEscapeRecord / verifyFloorEscapeRecord', () => { + it('подписывает (+sig 64hex) и верифицирует целую запись', () => { + const signed = signFloorEscapeRecord(REC, KEY); + expect(signed.sig).toMatch(/^[0-9a-f]{64}$/); + expect(signed.action).toBe(REC.action); + expect(verifyFloorEscapeRecord(signed, KEY)).toBe(true); + }); + it('false на подделке / без sig / без ключа / чужом ключе', () => { + const signed = signFloorEscapeRecord(REC, KEY); + expect(verifyFloorEscapeRecord({ ...signed, action: 'bash:rm -rf /' }, KEY)).toBe(false); + expect(verifyFloorEscapeRecord(REC, KEY)).toBe(false); // нет sig + expect(verifyFloorEscapeRecord(signed, null)).toBe(false); + expect(verifyFloorEscapeRecord(signed, 'other-key')).toBe(false); + }); + it('доменная изоляция: approval-подпись НЕ проходит как floor-escape', () => { + const asApproval = signApprovalRecord(REC, KEY); // домен APPROVAL + expect(verifyFloorEscapeRecord(asApproval, KEY)).toBe(false); + }); + it('без ключа → sig:null', () => { + expect(signFloorEscapeRecord(REC, null).sig).toBe(null); + }); +}); +``` + +- [ ] **Step 3: RED-прогон** — из `app/`: `node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs receipt-sign-floor-escape askuser-answer-parser-floor-escape --reporter dot` → FAIL (домена/helpers нет). + +- [ ] **Step 4: реализация receipt-sign.mjs** — в `RECEIPT_DOMAINS` (после `VERIFY_PASS: 'verify-pass',`): +```javascript + FLOOR_ESCAPE: 'floor-escape', +``` + +- [ ] **Step 5: реализация askuser-answer-parser.mjs** — после `verifyApprovalRecord` (после строки 218): +```javascript + +/** Подписать floor_escape-пропуск (M6 FIX-5, домен FLOOR_ESCAPE — зеркало signApprovalRecord). + * Без ключа — sig:null (downstream key-gated verify → принять как сегодня / отбросить при ключе). */ +export function signFloorEscapeRecord(record, key) { + return { ...record, sig: signPayload(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE) }; +} + +/** Проверить подпись floor_escape-пропуска. Неподписанная/подделанная/без ключа/чужой домен → false. */ +export function verifyFloorEscapeRecord(record, key) { + return verifyReceipt(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE); +} +``` + +- [ ] **Step 6: GREEN-прогон** — повторить Step 3 → PASS. +- [ ] **Step 7: Commit** (владелец, msg в `.scratch/`). + +--- + +## Task 2: writer подписывает на записи (TDD) + +**Files:** Modify `enforce-askuser-answer-parser.mjs`; Create `enforce-askuser-answer-parser-floor-escape.test.mjs`. + +- [ ] **Step 1: RED — тест (Write)** +```javascript +// tools/enforce-askuser-answer-parser-floor-escape.test.mjs +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { processEvent } from './enforce-askuser-answer-parser.mjs'; +import { verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; + +function memFs() { + const s = new Map(); const norm = (p) => String(p).replace(/\\/g, '/'); + return { s, + appendFileSync: (p, d) => { const n = norm(p); s.set(n, (s.get(n) || '') + d); }, + mkdirSync: () => {} }; +} +const DIR = '/rt'; const KEY = 'test-receipt-key'; +const ev = (action) => ({ + session_id: 's1', + tool_input: { questions: [{ question: 'Q?' }] }, + tool_response: { answers: { 'Q?': `да, разрешаю. FLOOR-ESCAPE: ${action}` } }, +}); +function readLines(fs) { + const raw = fs.s.get(join(DIR, 'askuser-decisions-s1.jsonl').replace(/\\/g, '/')) || ''; + return raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} + +describe('processEvent — key-gated подпись floor_escape', () => { + it('ключ есть → floor_escape несёт валидную подпись', () => { + const fs = memFs(); + processEvent(ev('bash:git push --force'), { runtimeDir: DIR, nowMs: 5, keyImpl: () => KEY, fsImpl: fs }); + const esc = readLines(fs).find((r) => r.type === 'floor_escape'); + expect(esc).toBeTruthy(); + expect(verifyFloorEscapeRecord(esc, KEY)).toBe(true); + }); + it('ключ null → floor_escape без подписи (как сегодня)', () => { + const fs = memFs(); + processEvent(ev('bash:git push --force'), { runtimeDir: DIR, nowMs: 5, keyImpl: () => null, fsImpl: fs }); + const esc = readLines(fs).find((r) => r.type === 'floor_escape'); + expect(esc.sig).toBeUndefined(); + }); +}); +``` +> NB: тест передаёт `fsImpl` в processEvent — добавить инъекцию `fsImpl` в Task 2 Step 3 (для hermetic-теста без записи в реальный runtime). + +- [ ] **Step 2: RED-прогон** — `node ... enforce-askuser-answer-parser-floor-escape --reporter dot` → FAIL (подписи нет / `fsImpl` не инъектируется). + +- [ ] **Step 3: реализация enforce-askuser-answer-parser.mjs** + +3a. импорты (после строки 19 `import { toApprovalRecord, toFloorEscapeRecord } from './askuser-answer-parser.mjs';`): +```javascript +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { resolveReceiptKey } from './receipt-key-config.mjs'; +``` + +3b. сигнатура `processEvent` (строка 29) — добавить `keyImpl` + `fsImpl`: +```javascript +export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync } } = {}) { +``` +(заменяет существующую сигнатуру; `appendFileSync`/`mkdirSync` уже импортированы из `node:fs` строкой 16 — использовать `fsImpl.appendFileSync`/`fsImpl.mkdirSync` ниже вместо прямых.) + +3c. резолв ключа (внутри try, после `const answers = ...`, ~строка 37): +```javascript + let key = null; try { key = keyImpl(); } catch { key = null; } +``` + +3d. подпись esc (заменить `const esc = toFloorEscapeRecord(ans, { nowMs });` ~строка 48): +```javascript + let esc = toFloorEscapeRecord(ans, { nowMs }); + if (esc && key) esc = signFloorEscapeRecord(esc, key); +``` + +3e. заменить прямые `mkdirSync(...)` / `appendFileSync(...)` (строки 52, 55) на `fsImpl.mkdirSync(...)` / `fsImpl.appendFileSync(...)`. + +- [ ] **Step 4: GREEN-прогон** — повторить Step 2 → PASS. + регрессия существующего `enforce-askuser-answer-parser` теста (подпись не ломает approve_git_operation-путь): `node ... enforce-askuser-answer-parser --reporter dot`. +- [ ] **Step 5: Commit** (владелец). + +--- + +## Task 3: reader key-gated verify (TDD) + +**Files:** Modify `escape-grant.mjs`; Create `escape-grant-floor-escape.test.mjs`. + +- [ ] **Step 1: RED — тест (Write)** +```javascript +// tools/escape-grant-floor-escape.test.mjs +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { loadFloorEscapes } from './escape-grant.mjs'; +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; + +function memFs(seed = {}) { + const norm = (p) => String(p).replace(/\\/g, '/'); + const s = new Map(Object.entries(seed).map(([k, v]) => [norm(k), v])); + return { s, + existsSync: (p) => s.has(norm(p)), + readFileSync: (p) => { const n = norm(p); if (!s.has(n)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(n); } }; +} +const DIR = '/rt'; const KEY = 'test-receipt-key'; const NOW = 1000; +const file = (recs) => ({ [join(DIR, 'askuser-decisions-s1.jsonl')]: recs.map((r) => JSON.stringify(r)).join('\n') + '\n' }); +const signed = (action) => signFloorEscapeRecord({ type: 'floor_escape', action, ts: NOW }, KEY); +const unsigned = (action) => ({ type: 'floor_escape', action, ts: NOW }); + +describe('loadFloorEscapes — key-gated подпись', () => { + it('ключ есть → подписанный принят, неподписанный/битый отброшены', () => { + const fs = memFs(file([signed('bash:real'), unsigned('bash:forged'), { ...signed('bash:tampered'), action: 'bash:evil' }])); + const g = loadFloorEscapes('s1', NOW, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: DIR }); + expect(g.map((x) => x.action)).toEqual(['bash:real']); + }); + it('ключ null → все приняты (backward-compat, content-floor backstop)', () => { + const fs = memFs(file([unsigned('bash:a'), signed('bash:b')])); + const g = loadFloorEscapes('s1', NOW, { keyImpl: () => null, fsImpl: fs, runtimeDir: DIR }); + expect(g.map((x) => x.action).sort()).toEqual(['bash:a', 'bash:b']); + }); + it("ключ '' (falsy) → трактуется как нет ключа → все приняты", () => { + const fs = memFs(file([unsigned('bash:a')])); + const g = loadFloorEscapes('s1', NOW, { keyImpl: () => '', fsImpl: fs, runtimeDir: DIR }); + expect(g.map((x) => x.action)).toEqual(['bash:a']); + }); + it('окно 5 мин и форма {action,ts} сохранены', () => { + const old = signFloorEscapeRecord({ type: 'floor_escape', action: 'bash:old', ts: NOW - 6 * 60 * 1000 }, KEY); + const fs = memFs(file([signed('bash:fresh'), old])); + const g = loadFloorEscapes('s1', NOW, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: DIR }); + expect(g).toEqual([{ action: 'bash:fresh', ts: NOW }]); + }); +}); +``` + +- [ ] **Step 2: RED-прогон** — `node ... escape-grant-floor-escape --reporter dot` → FAIL (нет key-gated verify / нет инъекции). + +- [ ] **Step 3: реализация escape-grant.mjs** + +3a. импорты (после строки 13 `import { pathNormalizeSafe } from './path-normalization.mjs';`): +```javascript +import { resolveReceiptKey } from './receipt-key-config.mjs'; +import { verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import fsDefault from 'node:fs'; +``` + +3b. заменить `loadFloorEscapes` (строки 87-89) + `loadRecords` (строки 106-118) на: +```javascript +/** Полные floor_escape-записи сессии (с sig), без stripping. */ +function readFloorEscapeRecords(sessionId, fsImpl) { + const path = join(homedir(), '.claude', 'runtime', `askuser-decisions-${sessionId || 'unknown'}.jsonl`); + if (!fsImpl.existsSync(path)) return []; + const out = []; + try { + for (const line of fsImpl.readFileSync(path, 'utf-8').split(/\r?\n/)) { + if (!line.trim()) continue; + let r; try { r = JSON.parse(line); } catch { continue; } + if (r && r.type === 'floor_escape' && typeof r.action === 'string') out.push(r); + } + } catch { return []; } + return out; +} + +/** I/O: floor_escape-пропуски сессии (зеркало shell-content::loadApprovedGitOps). M6 FIX-5: + * key-gated подпись — ключ есть → оставить только валидно-подписанные (форж/неподписанный отброшен); + * нет ключа (truthy) → принять все (текущее поведение, content-floor backstop). keyImpl/fsImpl/runtimeDir + * инъектируемы для тестов; runtimeDir по умолчанию через homedir внутри readFloorEscapeRecords. */ +export function loadFloorEscapes(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { + let key = null; try { key = keyImpl(); } catch { key = null; } + const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir); + 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 <= FLOOR_ESCAPE_WINDOW_MS); +} +``` +> **Уточнение пути (для теста с `runtimeDir`):** `readFloorEscapeRecords` хардкодит `homedir()/.claude/runtime`. Чтобы тест с `runtimeDir: DIR` работал, ввести `readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir)`: `const base = runtimeDir || join(homedir(), '.claude', 'runtime'); const path = join(base, ...)`. Заменить `readFloorEscapeRecords` на `readFloorEscapeRecordsAt` с параметром `runtimeDir`. + +3c. **NB:** `loadRecords` удаляется (его единственный вызыватель — `loadFloorEscapes`, заменён `readFloorEscapeRecordsAt`). `loadConsumed` (строки 91-104) не трогается — читает ДРУГОЙ файл напрямую. + +- [ ] **Step 4: GREEN-прогон** — повторить Step 2 → PASS. + регрессия существующего `escape-grant` теста (key-gated не ломает старые тесты — они зовут `loadFloorEscapes` без ключа/с memFs): `node ... escape-grant --reporter dot`. +- [ ] **Step 5: Commit** (владелец). + +--- + +## Гейт закрытия (после Task 3) +- [ ] `audit-context` — перечитать изменённую поверхность (4 файла + 4 теста). +- [ ] `sharp-edges` — «ключ есть»=truthy на обеих сторонах; подпись==хранимое-минус-sig; fail-closed не клинит штатную дверь. +- [ ] `variant-analysis` — нет ли иного ВЫДАЮЩЕГО читателя (доска — display-only, VA-1); `loadRecords` удалён без осиротевших вызывателей; approve_git_operation/loadApprovedGitOps не задеты. +- [ ] regression tools-only `node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs --reporter dot` ≥ **3465+2skip + новые** (Task 1 ~6 + Task 2 ~2 + Task 3 ~4 ≈ 3477), 0 регрессий. +- [ ] `verification-before-completion`. + +--- + +## Self-Review +**Spec coverage:** §4 C1 (домен) → Task 1; C2 (helpers) → Task 1; C3 (writer) → Task 2; C4 (reader) → Task 3. §6 таблица поведения → Task 3 тесты (ключ есть/нет × signed/unsigned/forged). §11.1 VA-1 (доска display-only) → гейт variant-analysis. ✅ +**Placeholder scan:** код всех правок/тестов приведён; точные строки-якоря (receipt-sign VERIFY_PASS, askuser-answer-parser :218, enforce-parser :19/:29/:48/:52/:55, escape-grant :13/:87-89/:106-118). ✅ +**Type consistency:** `signFloorEscapeRecord(record, key)`/`verifyFloorEscapeRecord(record, key)` — единые сигнатуры; `loadFloorEscapes(sessionId, now, {keyImpl, fsImpl, runtimeDir})` — расширенная сигнатура (3-й опц. аргумент, обратно-совместима с `loadFloorEscapes(sess)`/`loadFloorEscapes(sess, now)` существующих вызывателей). ✅ +**Обратная совместимость вызывателей:** 8 потребителей зовут `loadFloorEscapes(sess)` — новый 3-й аргумент опционален (default resolveReceiptKey/fsDefault) → не ломаются. ✅ +**Не-цели (YAGNI):** approve_git_operation подпись; consumed подпись; доска key-gated; флаг-рубильник. diff --git a/docs/superpowers/specs/2026-06-10-floor-escape-signing-design.md b/docs/superpowers/specs/2026-06-10-floor-escape-signing-design.md new file mode 100644 index 00000000..021b63e7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-floor-escape-signing-design.md @@ -0,0 +1,138 @@ +# Дизайн: подпись escape-гранта (M6 FIX-5, key-gated) — defense-in-depth + +**Дата:** 2026-06-10 · **Кодовая фраза эпика:** «роутер-наставник» · **Статус:** ЧЕРНОВИК (ждёт 2 адверсариальных прохода + ревью владельца). +**Тип:** design-only. Кода нет. Реализация — отдельный заход по слову владельца. +**Цепочка дизайна (согласована владельцем):** `brainstorming` → черновик → **разбор #1 + разбор #2** (`sharp-edges` → `variant-analysis` → `systematic-debugging`, оба по написанной спеке) → spec self-review → ревью владельца. `writing-plans` — следующая фаза. + +--- + +## 1. Цель и почему это defense-in-depth + +Аварийная дверь владельца (escape) — разовый «пропуск» `{type:'floor_escape', action, ts}`, который среда ([enforce-askuser-answer-parser.mjs](../../../tools/enforce-askuser-answer-parser.mjs)) пишет в `~/.claude/runtime/askuser-decisions-.jsonl` при реальном AskUser-выборе владельца, содержащем токен `FLOOR-ESCAPE: `. Потребители (пол М5 / стена М2 / гашение М6) читают пропуска через единственный читатель [escape-grant.mjs::loadFloorEscapes](../../../tools/escape-grant.mjs#L87) и матчат по `action`+`ts`+5-мин-окну. + +**Угроза:** контроллер форжит запись `floor_escape` напрямую в runtime → фабрикует «одобрение владельца» на произвольное действие. **Сейчас закрыто content-floor'ом** (М5: `node -e fs.*` / PowerShell-запись в runtime hard-blocked, C-1 аудита). FIX-5 добавляет **подпись**: даже форжённая запись (если бы прошла мимо content-floor) без секретного ключа не несёт валидной подписи → отвергается. + +**Статус deferral (аудит 2026-06-09 §M6):** FIX-5 был осознанно отложен — «целостность гранта держит защита записи в runtime, а не подпись; spec §6 не обязательно». Владелец расконсервировал его как belt-and-suspenders (как `judge_mode` seal-time guard 2026-06-09). + +## 2. Решения владельца (брейнсторм 2026-06-10) + +1. **Раскатка = key-gated (вариант А).** Reader требует подпись ТОЛЬКО когда ключ доступен; нет ключа → принимает неподписанный (как сегодня, content-floor — backstop). Writer подписывает, когда ключ есть. Энфорсмент авто-включается при провижининге ключа в Фазе 8. Не key-AND-flag (вариант Б отклонён — лишний рубильник + риск порядка), не always-required (В отклонён — сложно/рискованно для аварийной двери). +2. **Объём = только `floor_escape`.** `approve_git_operation` (та же файл-шина, другой потребитель `shell-content-rules::loadApprovedGitOps`) — НЕ трогаем; его подделка — отдельный пункт. + +## 3. Архитектура + +Подпись ставится у **писателя** (среда, при реальном AskUser-событии) и проверяется у **единственного читателя** — оба через `resolveReceiptKey()` (тот же keychain `router-mentor-receipts`, тот же OS-пользователь). Ключ контроллеру недоступен → подделать подпись он не может. + +``` +Владелец → AskUser-выбор с «FLOOR-ESCAPE: » + │ (PostToolUse) enforce-askuser-answer-parser.processEvent + ▼ +toFloorEscapeRecord → {type:'floor_escape', action, ts} + │ key = resolveReceiptKey() ; если key → signFloorEscapeRecord(rec, key) → +sig + ▼ +append в askuser-decisions-.jsonl (подписанная, если ключ есть; иначе как сегодня) + │ +Потребители (пол/стена/гашение) → loadFloorEscapes(sess) + │ key = resolveReceiptKey() + │ есть key → оставить только verifyFloorEscapeRecord(rec,key)===true (неподписанные/битые отброшены) + │ нет key → оставить все (текущее поведение, content-floor backstop) + ▼ +findOpenGrant / escapeGrantOpen — без изменений (получают уже-отфильтрованные гранты) +``` + +**Единственная ВЫДАЮЩАЯ точка чтения** = `loadFloorEscapes`; проверка подписи в ней автоматически покрывает всех потребителей, которые ОТКРЫВАЮТ дверь (пол/стена/egress/read-страж/нормативный/verify/criterion/гашение — все читают через неё, см. разбор #1 VA-1). Доска (`guard-block-log::loadRecentEscapes`) — отдельный НЕ-выдающий читатель того же файла (только показывает; форж там не открывает дверь — §11.1 VA-1). + +## 4. Компоненты (3 точки касания + 2 helper'а, зеркало APPROVAL) + +| # | Файл | Что | +|---|---|---| +| C1 | [receipt-sign.mjs](../../../tools/receipt-sign.mjs) | +домен `FLOOR_ESCAPE: 'floor-escape'` в `RECEIPT_DOMAINS` (R-31, чтобы подпись floor-escape не принималась за approval/frozen-plan и наоборот) | +| C2 | [askuser-answer-parser.mjs](../../../tools/askuser-answer-parser.mjs) | +`signFloorEscapeRecord(record, key)` / `verifyFloorEscapeRecord(record, key)` — **зеркало** существующих `signApprovalRecord`/`verifyApprovalRecord` ([:211-218](../../../tools/askuser-answer-parser.mjs#L211)), но домен `FLOOR_ESCAPE`. Чистые. | +| C3 | [enforce-askuser-answer-parser.mjs](../../../tools/enforce-askuser-answer-parser.mjs)::`processEvent` | при записи `esc`: `key = keyImpl()` (default `resolveReceiptKey`, инъектируем); если `key` → `esc = signFloorEscapeRecord(esc, key)`. `approve_git_operation` (`rec`) НЕ подписываем (§2.2). Fail-open сохранён (подпись не должна ломать PostToolUse-наблюдаемость — ошибка резолва ключа → пишем неподписанным). | +| C4 | [escape-grant.mjs](../../../tools/escape-grant.mjs)::`loadFloorEscapes` | key-gated верификация: читать ПОЛНЫЕ floor_escape-записи `{type, action, ts, sig}`; `key = keyImpl()` (default `resolveReceiptKey`, инъектируем); `key` есть → оставить только `verifyFloorEscapeRecord(rec, key)===true`; нет ключа → оставить все; затем map→`{action, ts}` + 5-мин-окно (как сейчас). `loadRecords` (приватный, используется только здесь) при необходимости вернуть полную запись вместо stripped. | + +## 5. Формы данных + +- **Запись floor_escape (без ключа):** `{type:'floor_escape', action:'', ts:}` (как сегодня). +- **Запись floor_escape (с ключом):** `{type:'floor_escape', action, ts, sig:'<64hex>'}`. `sig = signPayload({type, action, ts}, key, 'floor-escape')` — подписанный payload = запись БЕЗ `sig` (ровно `{type, action, ts}`). `verifyFloorEscapeRecord` strip'ает `sig`, пересчитывает над остатком, `timingSafeEqual` (как `verifyReceipt`). + +**Инвариант согласованности (SD-критично):** подписываемое == хранимое-минус-sig. Писатель подписывает РОВНО `{type, action, ts}` и хранит РОВНО `{type, action, ts, sig}` — никаких лишних полей (иначе `verifyReceipt` включит их в пересчёт → mismatch → настоящий грант отброшен). `canonicalJson` сортирует ключи → порядок не важен. + +## 6. Поведение key-gated (таблица) + +| Ключ у reader | Запись | Результат | Комментарий | +|---|---|---|---| +| нет | без sig | **принят** | текущее поведение; content-floor — backstop | +| нет | с sig | **принят** | sig игнорируется (нечем проверять); не хуже | +| есть | валидный sig | **принят** | настоящий пропуск (подписан писателем при наличии ключа) | +| есть | без sig | **отброшен** | форж без ключа не может подписать → защита | +| есть | битый/чужой sig | **отброшен** | подделка/чужой домен → защита | + +Направление **fail-closed**: при ключе сомнение → дверь не открывается (отвергаем). Настоящие пропуска подписаны → не страдают. + +## 7. Граничные случаи / угрозы (вход для разборов) + +- **Рассинхрон writer/reader по ключу:** оба `resolveReceiptKey` (тот же keychain/OS-пользователь) → симметрично. Пограничный момент «ключ провижинен МЕЖДУ выдачей (unsigned) и чтением (key present) одного пропуска» → пропуск отброшен; но окно 5 мин + одноразовость → ничтожно. Зафиксировать как несущую симметрию. +- **Обходной читатель сырого файла:** проверить (variant), что floor_escape читает ТОЛЬКО `loadFloorEscapes` — иначе обходной читатель форж не отфильтрует. (`loadConsumed` читает ДРУГОЙ файл `floor-escape-consumed-*`; consume гасит, не выдаёт.) +- **Гашение (consumed):** подделка «гашения» лишь ЗАКРОЕТ свою же дверь (DoS на себя), не откроет чужую → подпись consumed вне scope (YAGNI). +- **Анти-инъекция:** `action` берётся из `FLOOR-ESCAPE: <...>` токена, нормализован `canonicalAction` у потребителя; подпись над ним; домен изолирует (R-31). +- **fail-open писателя:** ошибка резолва ключа в `processEvent` → пишем неподписанным (PostToolUse-наблюдаемость не ломаем); при ключе у reader такой пропуск отбросится (редкий баг, не дыра — дверь можно перевыдать). + +## 8. Вне scope (YAGNI) +`approve_git_operation` подпись (§2.2) · подпись consumed-записей (DoS-на-себя) · отдельный флаг-рубильник (key-gated его заменяет) · ротация/версионирование ключа. + +## 9. Стратегия тестирования (TDD-инварианты — для writing-plans) + +- C1: `RECEIPT_DOMAINS.FLOOR_ESCAPE === 'floor-escape'`; подпись floor-escape НЕ проходит как approval/frozen-plan (доменная изоляция). +- C2: `signFloorEscapeRecord({type,action,ts}, key)` → `+sig` (64hex); `verifyFloorEscapeRecord` true на целой, false на подделке/без sig/без ключа/чужом домене. +- C3 (processEvent): ключ есть → записанный floor_escape несёт валидный sig; ключ null → без sig (как сегодня); `approve_git_operation` НЕ подписан (регрессия). Инъекция `keyImpl`/`runtimeDir`/`nowMs`. +- C4 (loadFloorEscapes): ключ есть + подписанный → возвращён; ключ есть + неподписанный/битый → отброшен; ключ null → все возвращены (backward-compat); затем окно/`{action,ts}` как раньше. Инъекция `keyImpl`. +- Поток: writer-подписал (ключ) → reader (ключ) принял; форж (unsigned, ключ) → reader отбросил; pre-key (нет ключа) обе стороны → как сегодня. +- Регрессия tools-only ≥ baseline (план уточнит число; текущий 3465+2skip). + +## 10. Интеграция (anchors) +- Не трогаем: `findOpenGrant`/`escapeGrantOpen`/`canonicalAction` (получают уже-отфильтрованные гранты), потребителей (пол/стена/гашение — читают через `loadFloorEscapes`), `approve_git_operation`-путь. +- Правим: `receipt-sign` (домен), `askuser-answer-parser` (helpers), `enforce-askuser-answer-parser` (подпись на записи), `escape-grant` (key-gated verify на чтении). +- Переиспользуем: `signPayload`/`verifyReceipt` (receipt-sign), `resolveReceiptKey` (receipt-key-config), паттерн `signApprovalRecord`. + +## 11. Открытые пункты для адверсариального разбора (разбор #1/#2) +- Точно ли `loadFloorEscapes` — единственный читатель floor_escape (обходной путь)? +- Рассинхрон writer/reader ключа: есть ли реальный сценарий, где reader видит ключ, а writer нет (или наоборот)? +- fail-closed направление: не клинит ли key-gated отбраковка легитимную дверь при штатной работе писателя? +- Доменная изоляция: не путается ли `floor-escape` с `approval` (обе из того же файла/парсера)? +- `loadRecords` рефактор (strip→full): не ломает ли другие вызовы (подтвердить, что вызыватель один)? +- Класс «текст контроллера ≠ факт»: не появляется ли путь, где грант принимается по строке контроллера, а не по реальному AskUser-событию + подписи. + +## 11.1 Разбор #1 — sharp-edges → variant-analysis → systematic-debugging + +| # | Severity | Находка | Закрытие в дизайне | +|---|---|---|---| +| **VA-1** | **Med** | **Второй читатель floor_escape:** `guard-block-log::loadRecentEscapes` (доска, собрана 2026-06-10) читает те же записи. `loadFloorEscapes` — НЕ единственный читатель. | Доска **не выдающая** (display-only): форж там лишь покажется на дашборде, дверь НЕ откроет (выдача — только через key-gated `loadFloorEscapes`). **Безопасность не затронута.** Остаточно: post-key доска покажет форж-unsigned как «escape» (вводит в заблуждение, не дыра). Доп. key-gated проверка доски — **вне scope (YAGNI)**, зафиксировано как осознанный остаток. | +| VA-2 | info | `loadRecords` (escape-grant) приватный, единственный вызыватель — `loadFloorEscapes`. | Рефактор strip→full безопасен (нет других вызывателей). `loadConsumed` читает ДРУГОЙ файл напрямую, не через `loadRecords`. | +| SE-1 | Low | **Coupling формы подписи:** `verifyReceipt` сверяет запись-минус-sig с подписанным payload; добавление поля в `toFloorEscapeRecord` без синхронной правки обеих сторон → mismatch. | `signFloorEscapeRecord` подписывает ВЕСЬ `record`, `verifyFloorEscapeRecord` верифицирует ВЕСЬ `record`-минус-sig → симметрично при любой форме (обе берут полную запись). Робастно к добавлению полей, **пока хранимое == подписанное+sig** (§5 инвариант). | +| SE-2 | Low | **Пустой ключ:** `resolveReceiptKey` может вернуть `''` vs `null`. `signPayload(_, '', _)` → `null` (falsy key); `verifyReceipt` с `''` → false. | «Ключ есть» определяется как **truthy** (`if (key) ...`) на ОБЕИХ сторонах: `''`/`null`/`undefined` → трактуются как «нет ключа» → принять все (consistent, не клинит дверь). | +| SD-1 | info (бонус) | **ts в подписи** предотвращает replay: подписан `{type, action, ts}`; форж не может скопировать `action` со свежим `ts` и переиспользовать старую подпись (sig сломается). | Подпись над `ts` — бонус-защита от ts-tampering replay сверх forge-защиты. | +| SD-2 | info | fail-open писателя (key resolve бросил) → unsigned; reader с ключом отбросит → дверь для ЭТОГО пропуска не открылась. | Редкий баг, не дыра — пропуск перевыдаётся. PostToolUse-наблюдаемость не ломаем (§7). | + +## 11.2 Разбор #2 — независимый проход (новые классы) + +Методика R7: абстрактная классификация (множественные читатели / форма подписи / рассинхрон ключа / cross-session / доменная путаница / соседний approve_git_operation), потом перечисление. **Новых структурных/HIGH-дыр сверх #1 нет.** + +| # | Класс | Вывод | +|---|---|---| +| P2-1 | **Cross-session форж** | `loadFloorEscapes(sess)` читает только файл текущей сессии; форж должен таргетить текущий файл (content-floor защищает запись в runtime; подпись — второй слой). Не новый класс. | +| P2-2 | **Соседний `approve_git_operation`** | Та же файл-шина, но `loadApprovedGitOps` (shell-content-rules) фильтрует `type==='approve_git_operation'` → игнорирует floor_escape; добавление `sig` к floor_escape его НЕ трогает. git-approval подпись остаётся **вне scope** (§2.2/§8) — известный отдельный пункт, не регрессия. | +| P2-3 | **Доменная путаница** | `signApprovalRecord` (домен `APPROVAL`) и `signFloorEscapeRecord` (домен `FLOOR_ESCAPE`) — R-31 изолированы: подпись одного домена не проходит проверку другого. Тест на изоляцию (§9 C1). | +| P2-4 | **Класс «текст контроллера ≠ факт»** | Грант рождается ТОЛЬКО из реального AskUser-события (`enforce-askuser-answer-parser` PostToolUse на ответ владельца с токеном `FLOOR-ESCAPE:`), не из текста контроллера; подпись добавляет аутентичность. Контроллер не пишет канал (runtime protected + теперь подпись). Новой дыры нет. | + +**Acceptable residual:** VA-1 (доска показывает форж post-key — display-only, не дыра) · git-approval подпись (§2.2) — оба осознанно вне scope, зафиксированы. + +## 12. Статус и следующие шаги +- [x] Дизайн сошёлся (брейнсторм, решения владельца §2). +- [x] **Разбор #1** по спеке (`sharp-edges` → `variant-analysis` → `systematic-debugging`): VA-1 (Med, доска-читатель — display-only, безопасность не затронута) + VA-2/SE-1/SE-2/SD-1/SD-2 (info/Low) — §11.1, все закрыты. +- [x] **Разбор #2** по спеке (новые классы): P2-1..P2-4 — новых структурных дыр нет, §11.2. +- [x] Spec self-review (плейсхолдеров нет / §3↔§5↔§7↔§11 консистентны / scope сфокусирован / неоднозначностей нет; «ключ есть» = truthy зафиксировано). +- [ ] **Ревью владельца** ← следующий шаг. +- [ ] `writing-plans` → план реализации. + +**Прод-код НЕ затронут** этой спекой (design-only). Defense-in-depth; энфорсмент авто-включается при провижининге ключа (Фаза 8), до того поведение неизменно.