docs(router-mentor): phase-8 state snapshot + M6 FIX-5 design/plan

- docs/superpowers/2026-06-10-phase8-state-snapshot.md — снимок состояния
  эпика «роутер-наставник» (что готово / owner-шаги / отложенное).
- M6 FIX-5 (подпись escape-гранта, key-gated, defense-in-depth): спека
  (одобрена, 2 адверсар. прохода + self-review) + bite-sized TDD-план.
  Реализация НЕ начата — design-only артефакты.

Кодовая фраза эпика: «роутер-наставник».
This commit is contained in:
Дмитрий
2026-06-10 05:04:52 +03:00
parent 84231a1470
commit e506a836e7
4 changed files with 488 additions and 14 deletions
+14 -14
View File
@@ -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-сессий.
@@ -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 перекрыта).
@@ -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; флаг-рубильник.
@@ -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-<sess>.jsonl` при реальном AskUser-выборе владельца, содержащем токен `FLOOR-ESCAPE: <action>`. Потребители (пол М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: <action>»
│ (PostToolUse) enforce-askuser-answer-parser.processEvent
toFloorEscapeRecord → {type:'floor_escape', action, ts}
│ key = resolveReceiptKey() ; если key → signFloorEscapeRecord(rec, key) → +sig
append в askuser-decisions-<sess>.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:'<canon>', ts:<ms>}` (как сегодня).
- **Запись 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), до того поведение неизменно.