diff --git a/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md b/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md index eed55e7d..18c9e35b 100644 --- a/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md +++ b/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md @@ -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: `). +Удалить экспорт `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) — добавлен для корректной одноразовости; согласовать на ревью плана. diff --git a/docs/superpowers/specs/2026-06-07-router-mentor-machine-6-design.md b/docs/superpowers/specs/2026-06-07-router-mentor-machine-6-design.md index ebff8c95..dae68d92 100644 --- a/docs/superpowers/specs/2026-06-07-router-mentor-machine-6-design.md +++ b/docs/superpowers/specs/2026-06-07-router-mentor-machine-6-design.md @@ -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::`) детерминирована для идентичного входа (порядок ключей стабилен) — **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: `), чтобы контроллер скопировал её во всплывающий вопрос дословно (**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-.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` — без поломок. | — |