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:
+14
-14
@@ -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), до того поведение неизменно.
|
||||
Reference in New Issue
Block a user