docs(router-mentor): M6 аудит плана — G-1 α (escape сквозь стену М2) + G-2/G-5/G-6/G-8

Аудит плана реализации (writing-plans self-review + audit-context-building сквозь
М1–М5 + sharp-edges). Главная находка G-1: верховная стена М2 (enforce-supreme-gate
Δ7 + разговорный режим) блокирует разрушительное/мутаторы независимо от пола → floor_escape
(только пол) был no-op сквозь стену. Вариант α (решение владельца): escape — сквозной
override, чтимый стеной (allow без продвижения указателя), полом, egress.

Спек: §3 +enforce-supreme-gate, §4 блок G-1 (сквозной escape) + G-5/G-6/G-8, §9 +патч,
§11 аудит-таблица. План: новый Пакет 4b (стена М2, TDD), Пакет 4 +G-2 (переписать блок
двери) +G-5 (точный токен) +G-6 (запрет override), активация +supreme-gate, self-review.

Проверено ОК: экспорты совпадают, М1/М3/М4 не ломаются, общий канал askuser-decisions
фильтр по type. Только дизайн-артефакты, кода нет. Без push (commit-not-push).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-07 18:18:59 +03:00
parent 3fb7a0517f
commit 20c85ede09
2 changed files with 121 additions and 5 deletions
@@ -359,7 +359,42 @@ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], n
return { block: false, reason: 'floor: запись в обычный файл' };
}
```
Удалить экспорт `approvalOpen` и его тесты в `floor-decide.test.mjs` (заменены escape-тестами). Сообщение в блоке оставить понятным владельцу (контроллер по нему строит AskUser с токеном `FLOOR-ESCAPE: <canonicalAction>`).
Удалить экспорт `approvalOpen`. **G-5:** блок-сообщение обязано выводить **точный** `canonicalAction(name, input, {normalizeImpl})` (контроллер копирует его во всплывающий вопрос дословно как `FLOOR-ESCAPE: <строка>` — иначе binding не совпадёт). **G-6:** ни `floor-decide.mjs`, ни `enforce-floor.mjs` не должны содержать подстроку `override` (даже в комментах) — иначе краснеет `m5-floor-invariants.test.mjs` (escape≠protection); используем только термин «escape»/«floor_escape».
- [ ] **Step 4.4b — G-2: переписать блок «дверь владельца Δ1» в `floor-decide.test.mjs`**
Блок `describe('floorDecide — дверь владельца Δ1 ...')` (≈строки 79-110, на `approvedGitOps`) после смены сигнатуры станет красным. **Заменить** его на escape-эквивалент (5 кейсов, сохранить покрытие):
```js
describe('floorDecide — аварийный выход (escape, exact+window+one-shot)', () => {
const now = 1_000_000;
const grant = (action, ts = now - 1000) => ({ action, ts });
it('migrate:fresh с совпавшим свежим пропуском → allow', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate:fresh')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(false);
});
it('пропуск на ЧУЖУЮ строку → block', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('просроченный (>5 мин) пропуск → block', () => {
expect(floorDecide({ toolUse: bash('php artisan db:wipe'),
escapeGrants: [grant('bash:php artisan db:wipe', now - 6 * 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('пустой список пропусков → block', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'), escapeGrants: [], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('будущий ts пропуска → block (нижняя граница времени)', () => {
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [grant('bash:php artisan migrate:fresh', now + 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
});
it('уже погашенный пропуск (one-shot) → block', () => {
const g = grant('bash:php artisan migrate:fresh');
expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
escapeGrants: [g], escapeConsumed: [{ action: g.action, ts: g.ts }], now, normalizeImpl: id }).block).toBe(true);
});
});
```
(Прежний комментарий-заголовок файла про «дверь Δ1 read-only approve_git_operation» — обновить под escape.)
- [ ] **Step 4.5 — RED+реализация в `enforce-floor.mjs`**
@@ -392,6 +427,72 @@ export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Da
---
## Пакет 4b — Сквозной escape в верховной стене (`enforce-supreme-gate.mjs`) [G-1 α]
> **G-1:** стена М2 (`enforce-supreme-gate`, matcher `*`, PreToolUse) независимо блокирует разрушительное in-plan (Δ7, стр.157) и любой мутатор в разговорном режиме. Без этого патча floor_escape (только пол) — no-op сквозь стену. Вариант α: стена честит floor_escape — allow **без** продвижения указателя (escape = out-of-band действие владельца, не шаг плана).
**Files:**
- Modify: `tools/enforce-supreme-gate.mjs`
- Test: `tools/enforce-supreme-gate.test.mjs` (существующий)
- [ ] **Step 4b.1 — audit-context-building** по `enforce-supreme-gate.mjs` (`decide`/`decideMode`/`runGate`/`main`; точка раннего allow).
- [ ] **Step 4b.2 — RED: тесты в `enforce-supreme-gate.test.mjs`**
```js
import { decideMode } from './enforce-supreme-gate.mjs';
describe('supreme-gate escape (M6 G-1 α)', () => {
const now = 1000;
it('разрушительное с совпавшим floor_escape (разговорный режим) → allow без advanceTo', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }], escapeConsumed: [], now });
expect(r.decision).toBe('allow');
expect(r.advanceTo).toBeUndefined();
});
it('без пропуска разговорный мутатор → block (поведение М2 не изменилось)', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [], escapeConsumed: [], now });
expect(r.decision).toBe('block');
});
it('погашенный пропуск (one-shot) → block', () => {
const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }],
escapeConsumed: [{ action: 'bash:git push --force', ts: now - 5 }], now });
expect(r.decision).toBe('block');
});
});
```
- [ ] **Step 4b.3 — Прогнать FAIL** (`... tools/enforce-supreme-gate.test.mjs ...`).
- [ ] **Step 4b.4 — Реализовать: ранний escape-allow в `decideMode`**
```js
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key,
escapeGrants = [], escapeConsumed = [], now = Date.now(),
verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
// G-1 (α): сквозной аварийный выход владельца — раньше всех плановых проверок.
// allow БЕЗ advanceTo (указатель не двигается; escape — out-of-band, не шаг плана).
if (escapeGrantOpen(canonicalAction(toolUse?.name, toolUse?.input || {}), escapeGrants, escapeConsumed, now)) {
return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' };
}
// ... существующее тело без изменений ...
}
```
`runGate` — принять `escapeGrants`/`escapeConsumed`/`now` и передать в `decideMode` (escape-allow без `advanceTo` → существующая ветка журналирования/saveStep не срабатывает, указатель не двигается — то что нужно). `main()` — загрузить и передать:
```js
const escapeGrants = loadFloorEscapes(sess);
const escapeConsumed = loadConsumed(sess);
// ... runGate({ ..., escapeGrants, escapeConsumed });
```
(NB: `enforce-supreme-gate` НЕ в override-списке `m5-floor-invariants` — термин escape допустим. Существующие тесты зовут `decideMode` без `escapeGrants` → default `[]``escapeGrantOpen` false → поведение М2 не меняется, обратная совместимость.)
- [ ] **Step 4b.5 — Прогнать PASS** + быстрый прогон `enforce-supreme-gate.test.mjs` целиком (старые тесты зелёные — backward-compat).
- [ ] **Step 4b.6 — Commit** (`feat(m6): G-1 сквозной escape в верховной стене М2 (allow без продвижения указателя)`).
---
## Пакет 5 — Одноразовое погашение: `floor-escape-consume.mjs` (PostToolUse)
**Files:**
@@ -768,14 +869,14 @@ Expected: **≥ 2789 passed + 2 skip** (новые тесты добавляют
- [ ] `superpowers:requesting-code-review` инлайн (самопроверка — суб-агент запрещён): пройтись по диффу против спека + 5 критериев аудита.
- [ ] `superpowers:verification-before-completion`: свежий полный прогон, привести вывод. Без него «готово» НЕ говорить.
- [ ] Память: topic-файл `project_router_mentor_machine_6_done.md` (кодовая фраза «роутер-наставник») + индекс MEMORY.md. Запись памяти = два охранника (`claude-md-management` активен в том же turn + `coverage: direct:memory-sync`).
- [ ] Handoff-заметка для активации владельцем (НЕ код): регистрация в `.claude/settings.json``enforce-snapshot` (PreToolUse, ПОСЛЕ enforce-floor) + `enforce-floor-escape-consume` (PostToolUse); `enforce-floor`/`enforce-askuser-answer-parser`/`enforce-mcp-classification` уже зарегистрированы (их правки активны после рестарта). Сперва тихий режим, затем hard-block.
- [ ] Handoff-заметка для активации владельцем (НЕ код): регистрация в `.claude/settings.json``enforce-snapshot` (PreToolUse, ПОСЛЕ enforce-floor) + `enforce-floor-escape-consume` (PostToolUse); `enforce-floor`/`enforce-askuser-answer-parser`/`enforce-mcp-classification`/`enforce-supreme-gate` уже зарегистрированы (их правки активны после рестарта). Сперва тихий режим, затем hard-block.
- [ ] **Пуш — ТОЛЬКО по слову владельца «пуш»** (`superpowers:finishing-a-development-branch`).
---
## Self-review (writing-plans)
**Покрытие спека:** §3 модули → Пакеты 1-8 (escape-grant П1; floor-decide/enforce-floor П4; askuser-answer-parser П2; enforce-askuser-answer-parser П3; enforce-mcp-classification П6; snapshot-decide П7; enforce-snapshot П8). One-shot (§4 F-S1) → П5 (новый floor-escape-consume — операционализация «погашения после применения»; **отклонение от инвентаря §9 спека**, оформлено как PostToolUse-консьюмер — отметить владельцу). 3 локуса (§4) → П4 (Bash+Write) + П6 (egress). binding (F3) → canonicalAction П1, тест П9.1. F-S2 → П7 resolveGitState. F4 (не-git одобряемы) → покрыто floor_escape (не git-only) — добавить явный тест в П2/П9. fail-close (§5) → П8.
**Покрытие спека:** §3 модули → Пакеты 1-9 + 4b (escape-grant П1; floor-decide/enforce-floor П4; askuser-answer-parser П2; enforce-askuser-answer-parser П3; **enforce-supreme-gate П4b (G-1 α)**; enforce-mcp-classification П6; floor-escape-consume П5; snapshot-decide П7; enforce-snapshot П8). One-shot (§4 F-S1) → П5. 3 локуса (§4) → П4 (Bash+Write) + П6 (egress); **+ сквозь стену М2 → П4b (G-1 α — иначе escape no-op end-to-end)**. binding (F3) → canonicalAction П1, тест П9.1; **G-5** точный токен в блок-сообщении пола (П4 Step 4.4). **G-6** запрет `override` в floor-файлах (П4). **G-2** переписан блок двери (П4 Step 4.4b). F-S2 → П7 resolveGitState. F4 (не-git одобряемы) → floor_escape (не git-only). fail-close (§5) → П8. **G-8** канон egress детерминирован (П1/спек §4).
**Плейсхолдеры:** нет (код во всех шагах).
**Типы:** `canonicalAction`/`escapeGrantOpen`/`loadFloorEscapes`/`loadConsumed`/`consumeDecision`/`snapshotNeeded`/`resolveGitState`/`snapshotDecision` — имена согласованы между пакетами. Грант-форма `{action, ts}` едина; consumed-форма `{action, ts}` едина; floor_escape-запись `{type:'floor_escape', action, ts}` едина.
**Новый модуль вне спека:** `floor-escape-consume` (П5) — добавлен для корректной одноразовости; согласовать на ревью плана.
@@ -45,6 +45,7 @@
| `tools/enforce-mcp-classification.mjs` | **правка существующего** | escape-проверка для egress-floor (третий локус, Р-М6-3=B). |
| `tools/floor-escape-consume.mjs` | новое, чистое ядро | одноразовое погашение: по исполненному действию + грантам/consumed → отметка-погашение. |
| `tools/enforce-floor-escape-consume.mjs` | новое, обёртка-хук (PostToolUse) | после **реального исполнения** floor-действия пишет отметку-погашение (one-shot); снимок-fail (PreToolUse-блок) → не исполнилось → пропуск не гасит. |
| `tools/enforce-supreme-gate.mjs` | **правка существующего (М2)** | **G-1 сквозной escape:** при свежем совпавшем floor_escape → allow (Δ7-разрушительное in-plan И мутатор в разговорном режиме), **без** продвижения указателя плана. Без этого стена М2 затеняет escape (оба гейта PreToolUse `*` — блок любого = блок). |
| `tools/snapshot-decide.mjs` | новое, чистое ядро | по `classifyDestructive` решает: нужен ли снимок + его описание; различает «чистое дерево» и «ошибка git». |
| `tools/enforce-snapshot.mjs` | новое, обёртка-хук (PreToolUse) | делает git-точку возврата ДО разрушительного действия, пишет журнал восстановления; fail-close на ошибке git (не на чистом дереве). |
@@ -86,7 +87,9 @@
**fail-CLOSE** на любую свою ошибку (нет канала / битый пропуск / двусмысленность) → блок.
**Замена «двери».** `floor-decide.approvalOpen` (git-only, только Bash) убираем; на его месте `escape-grant` во всех ветках. Правка `floor-decide`/`enforce-floor`/`askuser-answer-parser`/`enforce-askuser-answer-parser`/`enforce-mcp-classification` → на этапе стройки **обязателен** `audit-context-building` перед каждой.
**G-1 (сквозная М2↔М5) — escape это СКВОЗНОЙ override владельца, не только дверь пола.** Верховная стена `enforce-supreme-gate` (М2, matcher `*`) **независимо** блокирует разрушительное in-plan (Δ7, `enforce-supreme-gate.mjs:157`) и любой мутатор в разговорном режиме. Оба хука PreToolUse → блок ЛЮБОГО = блок действия. Значит floor_escape, открывающий только пол, **не работает сквозь стену**. Поэтому floor_escape консультируется **каждым защитным гейтом**: стеной М2 (allow без продвижения указателя — escape это out-of-band действие владельца, не шаг плана), полом М5 (Bash+Write), egress. Каноническая строка egress (`mcp:<tool>:<JSON.stringify(input)>`) детерминирована для идентичного входа (порядок ключей стабилен) — **G-8**.
**Замена «двери».** `floor-decide.approvalOpen` (git-only, только Bash) убираем; на его месте — `escape-grant` во всех ветках. Правка `floor-decide`/`enforce-floor`/`askuser-answer-parser`/`enforce-askuser-answer-parser`/`enforce-mcp-classification`/**`enforce-supreme-gate` (G-1)** → на этапе стройки **обязателен** `audit-context-building` перед каждой. Блок-сообщение пола обязано выводить **точную** каноническую строку (токен `FLOOR-ESCAPE: <canonicalAction>`), чтобы контроллер скопировал её во всплывающий вопрос дословно (**G-5**). `floor-decide`/`enforce-floor` не должны содержать подстроку `override` — инвариант escape≠protection `m5-floor-invariants.test.mjs` (**G-6**).
**Принятый остаточный риск (Р-М6-3 = B).** escape покрывает весь floor-набор, включая отправку секретов/ПДн и запись в runtime. Теоретически владельца можно уговорить одобрить опасное, обрамив «безобидно». Снижение: владелец видит **буквальную каноническую строку** (команда/путь/цель), а не пересказ. Полное устранение соц-инженерии невозможно — часть «~0.5% неустранимого предела» (§8). Боковых дверей мимо binding/парсера не делаем.
@@ -153,7 +156,8 @@
- `tools/enforce-floor.mjs` — подгрузка escape-пропусков и проброс в `floorDecide`.
- `tools/askuser-answer-parser.mjs``toFloorEscapeRecord(answer)` (новый, `kind:"floor_escape"`, переиспользует `normalizeCommand`/`buildApprovalRecord`).
- `tools/enforce-askuser-answer-parser.mjs` — писать `floor_escape`-записи в `askuser-decisions-<sess>.jsonl` (fail-open).
- `tools/enforce-mcp-classification.mjs` — escape-проверка для egress-floor (третий локус). *(NB: если владелец на ре-ревью решит оставить egress категорически жёстким — этот пункт снимается; пер B он включён.)*
- `tools/enforce-mcp-classification.mjs` — escape-проверка для egress-floor (третий локус).
- `tools/enforce-supreme-gate.mjs` (**G-1, М2**) — `decideMode` honors floor_escape: при свежем совпавшем пропуске → allow (`mode:'escape'`) **без** `advanceTo` (указатель не двигается, не журналируется); `runGate`/`main` грузят `loadFloorEscapes`/`loadConsumed`. Без этого стена затеняет escape. (NB: `enforce-supreme-gate` НЕ в override-списке `m5-floor-invariants`, термин escape допустим.)
**Тесты:** `m6-escape.test.mjs`, `m6-snapshot.test.mjs`.
@@ -192,3 +196,14 @@ TDD по модулям (RED→GREEN, реальный `expect`):
| I4 | — | §«Ревизия» + §11 — `agentic-actions-auditor` зафиксирован как неприменимый. |
**Поправка план→спек (2026-06-07):** при написании плана реализации выяснилось, что строгая одноразовость «погашение **после реального исполнения**» (§4 F-S1) требует отдельного PostToolUse-консьюмера. Добавлены модули `floor-escape-consume.mjs` (ядро) + `enforce-floor-escape-consume.mjs` (обёртка) в §3/§9; механизм синхронизирован с планом `docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md` (Пакет 5).
**Аудит плана (2026-06-07, audit-context-building сквозь М1–М5 + sharp-edges):**
| # | Находка | Правка |
|---|---|---|
| G-1 | КРИТ. сквозная: стена М2 (`enforce-supreme-gate` Δ7 + разговорный режим) блокирует разрушительное/мутаторы независимо от пола → floor_escape (только пол) был **no-op сквозь стену**. | **Вариант α (владелец):** escape — сквозной override, чтимый стеной М2 (allow без продвижения указателя), полом, egress. +патч `enforce-supreme-gate` в §3/§9; план Пакет 4b. |
| G-2 | Пакет 4 расплывчато «удалить тесты двери» — смена сигнатуры `floorDecide` краснит блок Δ1 (5 тестов) `floor-decide.test.mjs`. | План Пакет 4: конкретно переписать 5 кейсов двери в escape-форму. |
| G-5 | Привязка требует точного токена в варианте AskUser. | Блок-сообщение пола выводит точный `canonicalAction` (§4). |
| G-6 | `m5-floor-invariants` escape≠protection падает на подстроке `override` в floor-файлах. | Запрет термина `override` в `floor-decide`/`enforce-floor` (§4, план). |
| G-8 | Канон egress на `JSON.stringify`. | Отмечено как детерминированное для идентичного входа (§4). |
| ✅ | Экспорты (`classifyMcpTool`/`pathNormalize`/`bashIsFloor`/`normalizeCommand`) совпадают; М1 (`signApprovalRecord` домен APPROVAL), М3/М4 (`classifyDestructive` только чтение), общий канал `askuser-decisions` фильтр по `type` — без поломок. | — |