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:
@@ -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; флаг-рубильник.
|
||||
Reference in New Issue
Block a user