# Машина 5 — «Укрепление защиты-пола» — дизайн

**Дата:** 2026-06-07 · **Статус:** утверждён к реализации (brainstorming) · **Ветка:** `worktree-brainrepo`
**Кодовая фраза эпика:** «роутер-наставник».
**Источник:** корреляционный аудит М5 ↔ М1/М2/М3/М4 (Фазы 1-2: построчный контекст `audit-context-building` + 3 адверсариальные линзы — грабли / корректность / атака — по всему периметру). М5 на момент дизайна **СПРОЕКТИРОВАНА-НО-НЕ-ПОСТРОЕНА**.

---

## 1. Зачем (что вскрыл аудит)

Роадмап задумывал Машину 5 как «усиление 7 существующих хуков-полов». Аудит показал, что этого **недостаточно** — вскрылся архитектурный провал:

> **Машина 2 («верховная стена») — это стена СООТВЕТСТВИЯ ПЛАНУ, а не ПОЛ.** Она отвечает только на вопрос «есть ли это действие в одобренном плане?» и **никогда** — «запрещено ли это действие в принципе?». Поэтому легитимно одобренный шаг плана, который сам по себе необратим (`git push --force`, `migrate:fresh`, запись в секрет/runtime), стена **пропускает** — пол на её разрешающем пути не вызывается (`enforce-supreme-gate.mjs:124-152`).

Следствие: инвариант М5 «секреты / runtime / force-push / необратимое НЕПРОБИВАЕМЫ даже при мёртвом судье» **архитектурно не выполнен**:
- запланированный `git push --force` сейчас блокирует только **отдельный** Bash-хук (`shell-content-rules.mjs:177`), снимаемый независимо от стены;
- запланированный `migrate:fresh` не блокирует **никто** (Bash-гейт его в белом списке — `enforce-router-gate.mjs:94`; стена пропускает по членству в плане);
- если саму стену не зарегистрировали (шаг владельца, `enforce-supreme-gate.mjs:4`) — default-deny нет вообще.

Плюс — линчпин-проблема ключа: GREEN-штамп и печати плана подписаны **симметричным** HMAC, ключ читается **в том же процессе**, где работает контролируемый ИИ (`receipt-key-config.mjs:20-30`, читается даже через whitelisted `node <файл>`). Значит контроллер может подделать сам себе и GREEN, и печать → «неподделываемый GREEN» **ложен как построен**.

«Владелец одобрил *запечатать план*» ≠ «владелец одобрил *обойти пол*». Это **разные сигналы**, и М5 должна их развести.

---

## 2. Решения (зафиксированы с владельцем)

| # | Развилка | Решение |
|---|---|---|
| Р1 | Объём | **Полный**: несущий костяк + критерий-носитель + все 7 усилений в одном дизайне (изолированные блоки; стройка — поэтапно). |
| Р2 | Изоляция ключа | **Отдельный процесс-подписант**: HMAC остаётся, меняется «кто держит ключ» (side-channel-паттерн проекта). |
| Р3 | Граница М5↔М6 | Авто-снимок+freshness / портативный канал правок нормативки / растворение старого зоопарка хуков — **остаются в М6**; М5 даёт только **шов (интерфейсы)**. |
| Р4 | Подход к структуре | **A — «Пол-хук + подписант + усиления-патчи»** (чистое ядро + тонкая обёртка, как М1-М4). Отклонены B (монолит) и C (минимум). |

---

## 3. Архитектура — пять блоков

### Блок 1 — Несущий пол: вето-до-плана (`enforce-floor.mjs` + ядро `floor-decide.mjs`)

- **Срабатывает ПЕРВЫМ** — до seed / observe-only / проверки членства в плане. Валидная печать плана для списка «никогда нельзя» **нерелевантна**. *(закрывает W1)*
- **Жёстко-зашитый список в коде** (не в конфиге, который контроллер обнулит): force-push / перепись истории; `migrate:fresh|reset|refresh` + `db:wipe`; запись/отправка секретов (`.env`, ключи, имена keychain-сервисов); запись в `~/.claude/runtime/**`; необратимая отправка наружу.
- **Без ключа и без подписи** — работает всегда, в т.ч. до выдачи ключа и при мёртвом судье. *(закрывает W4/F2)*
- **Отдельная регистрация** в settings.json (matcher `*`), **независимо от стены М2** — снятие стены не снимает пол; чтобы пробить, нужно снять оба. *(закрывает SPOF W4)*
- **Контент-классификация ЖИВОГО действия** из сырого `tool_input` (не из `actionOf.object`), через общий `pathNormalize` + `tokenizeBash` **посегментно** — `cat x && git push --force` не прячет force-push за читающим сегментом. *(закрывает W2/F6)*
- **Посессионно-независим** (список в коде, не из per-session файла); сессию `unknown` трактует как максимальный запрет. *(закрывает F4)*
- **fail-CLOSED** на любую свою ошибку.

### Блок 2 — Изоляция ключа / неподделываемый GREEN и печати (`floor-signer`)

- **Только подписант держит ключ.** Подписывает: (а) по-критерийный GREEN/RED по факту реального прогона; (б) печати плана/артефакта по одобрению владельца. Запускается по **harness-событию**, контроллер не может вызвать его с подставным входом. HMAC сохраняется; меняется «кто держит ключ».
- **Ключ убираем из доступного контроллеру места:** (1) **запрет env-fallback** (`ROUTER_MENTOR_RECEIPT_KEY`/`ROUTER_MENTOR_JUDGE_KEY` как переменные) — закрывает прямой канал; (2) **сужение whitelist `node`** с «любой файл без `-e`» до allowlist известных скриптов ИЛИ через план — контроллер не запустит произвольный node для чтения ключа/записи в runtime *(закрывает находку про `node <файл>` + F6)*; ключ владелец провижинит только в окружение подписанта.
- **GREEN пишет ТОЛЬКО подписант** в подписанную служебку в защищённом пути, **привязанную к отпечатку прогнанного кода** (правка после прогона аннулирует), **новый домен** `RECEIPT_DOMAINS.M5_GREEN` (не дефолтный) *(закрывает R-31/F3)*, + детектор массовости (анти-228 липовых GREEN).
- **Пол от подписи НЕ зависит** (Блок 1 — чистый код). Подписи нужны только для *provenance* GREEN/печатей, которые читает судья. Подписант упал → пол стоит, судья деградирует «нет GREEN → блок» (fail-closed). Журналу как «доказательству авторства» больше не доверяем — только подписант-канал. *(закрывает F1/F-B8)*
- **Остаточный предел:** полную изоляцию даёт только HSM/YubiKey (v4.2); на одно-пользовательской Windows-машине абсолют недостижим. Подписант + сужение `node` + запрет env поднимают планку радикально (нельзя прочесть ключ в своём процессе), но не до абсолюта — это часть «~0.5% неустранимого предела».

### Блок 3 — Критерий-носитель: sealed `criterion_id` → по-критерийный GREEN → судья

**Критерий живёт в ЗАПЕЧАТАННОМ ПЛАНЕ, а не в трассе роутера М3** (роутер выбирает узлы — это не его поле). М3 **не трогаем**.

- **`writing-plans`** пишет шаги с критериями приёмки → **`plan-lock`** при заморозке присваивает каждому шагу `criterion_id`, **выведенный детерминированно из запечатанного содержимого** (нельзя выдумать на демо-этапе). *(закрывает «само-референтный критерий»)*
- **Ужесточение судьи:** GREEN засчитывается только если его `criterion_id` ∈ запечатанных id (сейчас `greenRuns[].criterion_id` со sealed **не сверяется**). *(закрывает F2)*
- **По-критерийный производитель GREEN** (новый; для этой цели заменяет «весь-прогон-разом» `enforce-verify-record`): эмитит `{criterion_id, green, code_fingerprint, coverage_of_changed}`, **подписан подписантом** (Блок 2).
- **Значимость — механически, не по флагу контроллера.** Сейчас `trivial:true`/`significant:false` в шаге (пишет контроллер) обнуляют K5. Меняем: значимость выводится из класса действия — любой мутирующий / помеченный классификатором шаг **значим** и обязан нести конкретный критерий. **Пустой критерий у значимого/мутирующего шага = fail-CLOSE.** *(закрывает C3/F13/F18)*
- **Мутационная проверка (P18) как условие настоящего GREEN:** сломай изменённый код — тест ОБЯЗАН покраснеть; остался зелёным → не проверяет → не GREEN.

### Блок 4 — Семь усилений существующих хуков-полов

Каждое = точечный патч (чистое ядро + тонкая обёртка).

1. **`read-path-deny` → +контент-скан.** Сейчас: только по путям, 4 пути. Добавить скан **содержимого выдачи** на секрет-паттерны (токен/ключ/PEM/строка подключения) на всех каналах чтения; путь-деналист — грубый пре-фильтр; fail-close сохранить. *(дыра 6)*
2. **`normative-content` → детерм-only + fail-CLOSE для защитного подмножества.** Для защищаемой нормативки (CLAUDE.md/memory/Pravila): LLM-деградация → fail-CLOSE (требует владельца), обёртка → fail-CLOSE; детерм-слои остаются; «память = совет, НИКОГДА не разрешение». *(H3)*
3. **`mcp-classification` → +скан исходящего.** Проверять, **что уходит наружу** (секрет-паттерны + размер; SQL/URL при неуверенном разборе → fail-close; блок `data:`/`file:`/IP-литерал). *(дыра про исходящее)*
4. **`workflow-gate` → inline-script + inheritance-proof + РЕГИСТРАЦИЯ.** Сейчас инертен, inline-`script` вне охвата. Включить; inline-`script` гейтить как `scriptPath` (хеш+одобрение+скан); `DANGEROUS_PATTERNS` +`keytar`/`getPasswordSync`/имена keychain-сервисов; наследование защиты доказывается пробой. *(C5)*
5. **`parallel-session-lock` → pid-liveness + РЕГИСТРАЦИЯ.** Сейчас инертен, freshness только по TTL, pid-liveness НЕТ. Добавить проверку «жив ли держатель» перед перехватом/доверием; включить. *(дыра 5)*
6. **`tdd` → мутационная проверка + покрытие изменённого.** Мутация вшита в по-критерийный GREEN (Блок 3). `tdd-gate` остаётся **дисциплиной** (fail-open + override допустимы); мутация/GREEN-пол — **защита** (без override); правило дисциплины и правило защиты — в разных хуках. *(дыра 8)*
7. **Неподделываемый GREEN/RED** — Блоки 2+3 (подписант + по-критерийный производитель).

**Консолидация:** три разных regex разрушительных команд (M4 `a2CaseSelect:84` / M3 `detectHighRisk:12` / floor) свести к одному каноническому **`classifyDestructive`**, потребляемому всеми. *(дрейф W/находка 3)*

### Блок 5 — Сквозные инварианты

- **Единое правило fail-CLOSE.** Любой хук, охраняющий защитный набор, при любой ошибке / нет зависимости / деградации LLM / нет ключа → **блок**. Дисциплинарные хуки могут оставаться fail-open. *(закрывает H1 — корень «пол открывается под сбоем»)*
- **Раздел escape≠protection — структурно.** override-фразы физически не доходят до защитного набора (защита в отдельном хуке **без** override-вокабуляра). **Тест-инвариант (CI):** защитный список enforce'ится только fail-close хуками без override; правка, переносящая защиту под override, падает в CI. *(дыра 8/W)*
- **Манифест-самопроверка регистрации.** На старте М5 проверяет, что пол-хук + защитные хуки реально зарегистрированы; иначе — громкий крик «пост пустой». Нельзя объявить «пол стоит» без подтверждения. *(W4/H4)*
- **Двунаправленный + упорядоченный reconcile.** Каждый значимый шаг = ровно одна журнальная запись по порядку → «нет записи → нет действия» enforce'ится на исполнении, не пост-фактум. *(W3)*
- **Посессионная независимость**, `unknown`-сессия = максимальный запрет. *(F4)*

---

## 4. Карта закрытия находок аудита

| Находка | Severity | Где закрывается |
|---|---|---|
| W1 членство в плане перевешивает пол | CRIT | Блок 1 (вето-до-плана) |
| W4 мёртвый судья + незарегистр. стена / SPOF | CRIT | Блок 1 (отдельная регистрация) + Блок 5 (манифест) |
| C1/F1 симметричный ключ в процессе | CRIT | Блок 2 (подписант, запрет env, сужение node) |
| C2 нет по-критерийного GREEN / поля критерия | CRIT | Блок 3 (criterion-носитель + производитель) |
| C3/F13/F18 пустой вход = проходит | CRIT | Блок 3 (значимость механически, пустой→fail-close) |
| C4 `migrate:fresh` в whitelist | CRIT | Блок 1 (в жёстком списке) |
| C5 workflow inline-script / node-file bypass | CRIT | Блок 2 (сужение node) + Блок 4.4 (inline-script) |
| W2/F6 матчинг по строке без контент-класса | HIGH | Блок 1 (контент-класс живого действия) |
| W3 «нет записи» пост-фактум, reconcile односторонний | HIGH | Блок 5 (двунаправл. reconcile) |
| H1 системный fail-open боевых слоёв | HIGH | Блок 5 (единое fail-close) |
| H3 normative LLM fail-open / wrapper fail-quiet | HIGH | Блок 4.2 |
| F3/F-B7 домен по умолчанию | HIGH | Блок 2 (`RECEIPT_DOMAINS.M5_GREEN`) |
| дыра 5 lock инертен / нет pid-liveness | MED | Блок 4.5 |
| дыра 6 read-path без контента | MED | Блок 4.1 |
| дыра 8 tdd-gate fail-open + override | MED→HIGH | Блок 4.6 + Блок 5 (escape≠protection) |
| W5/6a `artifact_id:null`+ref-less | MED | Блок 1 (пол не зависит от ref/artifact) |
| W6/6c повторное переиспользование одобрения | LOW | Блок 2 (по-occurrence подпись) + М6 one-shot |
| F4 `unknown`-сессия общий бак | MED | Блок 1/5 (посессионная независимость) |
| F5 step-ptr без проверки при нет-ключа | MED | контракт К6 (М4) + Блок 5 манифест |
| находка 3 три разных regex | MED | Блок 4 (canonical `classifyDestructive`) |
| дрейф: CLAUDE.md путь classifyBash | LOW | правка нормативки (М6/sync, не код М5) |

---

## 5. Шов М5↔М6 (только интерфейс)

М5 **экспортирует**, М6 **потребляет** (реализация авто-снимка / портативного канала / растворения зоопарка / escape — в М6):
- `classifyDestructive(command|action)` — единый канонический классификатор.
- Жёсткий floor-список (non-escapable) — как импортируемая константа/предикат.
- API подписанта (`floor-signer`) — подпись GREEN/печатей.
- API критерия (`criterion_id` из sealed plan + чтение по-критерийного GREEN).

---

## 6. Не входит в М5 (YAGNI → М6/М7)

Авто-снимок перед разрушительным; портативный normative-канал правок; escape-механизм; растворение старых дисциплинарных хуков (router-gate/branch-switch/verify-before-push/script-watcher/tdd-gate/decomposition/self-debrief/rationalization/coverage); доска видимости «кто на посту» (М7).

---

## 7. Остаточные пределы (честно)

Полную изоляцию ключа даёт только HSM/YubiKey (v4.2-фаза); сохраняется **~0.5% неустранимый предел** (7 фундаментальных ограничений из v4: goal-misalignment / recursive trust / info-theoretic leak / behavioral residual / external deps / user approval ambiguity / gate paradox). М5 радикально поднимает планку; абсолют — только с железом.

---

## 8. Инвентарь модулей (для writing-plans)

**Новое:**
- `tools/floor-decide.mjs` (чистое ядро вето-до-плана) + `tools/enforce-floor.mjs` (обёртка, matcher `*`, fail-close, отдельная регистрация).
- `tools/floor-signer.mjs` (процесс-подписант GREEN/печатей) + `RECEIPT_DOMAINS.M5_GREEN`.
- по-критерийный производитель GREEN (новый модуль; для этой цели заменяет whole-suite `enforce-verify-record`).
- `tools/classify-destructive.mjs` (единый канонический классификатор) — потребляют floor / M4 `a2CaseSelect` / M3 `detectHighRisk`.
- манифест-самопроверка регистрации (стартовый чек).

**Патчи существующих:**
- `enforce-read-path-deny` (+контент-скан), `enforce-normative-content-rules` (детерм-only + fail-close), `enforce-mcp-classification`/`mcp-tool-classifier` (+egress-скан), `enforce-workflow-gate` (inline-script + register), `enforce-parallel-session-lock`+`parallel-session-lock` (pid-liveness + register), `enforce-tdd-gate`/`tdd-real-test-verifier` (мутация/покрытие; раздел дисциплина/защита), `plan-lock` (sealed `criterion_id`), `judge-gate-floor` (greenRuns[].criterion_id ∈ sealed), `receipt-key-config`/`judge-key-config` (запрет env-fallback), `enforce-router-gate` (сужение `node` whitelist; `migrate:fresh` из whitelist → floor).

**Активация (шаг владельца, НЕ код):** регистрация новых хуков в `.claude/settings.json`; провижининг ключа подписанта вне доступного контроллеру окружения; сперва тихий режим (D28).

---

## 9. Тестирование

TDD per блок: чистые ядра — unit-тесты (вето-до-плана: планированный force-push/migrate:fresh → блок; пустой критерий значимого шага → блок; контент-класс посегментно; `unknown`-сессия → запрет). Подписант — тесты «контроллер не может подписать» (нет ключа в его окружении → подпись невозможна). Критерий — «GREEN с несверенным criterion_id не засчитан»; «GREEN после правки кода аннулирован отпечатком». Сквозные — CI-инвариант escape≠protection; манифест-самопроверка. Регрессия tools-only зелёная (базовая 2560).

---

## 10. Амендмент 2026-06-07 — фаза 4 (brainstorming + факт-аудит кода)

Перед планом проведено: (а) brainstorming по 3 развилкам с владельцем; (б) повторная адверсариальная вычитка через `audit-context-building` с **факт-проверкой каждого claim по реальному коду M1-M5**. Факт-проверка вскрыла два «призрака» в первых правках — они переписаны. Принцип: только практичные правки по факту, без фантазий.

### Подтверждено в коде (не призраки)
- C4 реальна: `migrate:fresh` в whitelist — `enforce-router-gate.mjs:94`.
- Δ1 опирается на готовый канал: `askuser-answer-parser.mjs` + `enforce-askuser-answer-parser.mjs` + домен `RECEIPT_DOMAINS.APPROVAL` (`receipt-sign.mjs:18`).
- Ключ в процессе: `resolveReceiptKey` тянет keychain/env в процессе хука (`receipt-key-config.mjs:28`); `node <любой-файл>` whitelisted (`enforce-router-gate.mjs:90`).
- Машинерия И-нения полов уже есть: `runGateLadder`/`finalGate` (`judge-orchestrator.mjs:21,46`).

### Развилки (решения владельца)

**Δ1 — Блок 1: дверь владельца уже в М5.** Floor едет в **hard-block** (не тихий режим). Узкая дверь: floor блокирует разрушительную операцию контроллера, но **осознанный** force-push/migrate:fresh владельца проходит через подтверждение по существующему каналу одобрения (домен `APPROVAL`). Контроллер канал не пишет — пишет gate-процесс по harness-событию AskUser. Полный escape остаётся в М6; в М5 — только узкая владельческая дверь. Снимает инверсию «пол сильнее владельца».

**Δ2 — Блок 3: отпечаток «по делу» + мутация.** `code_fingerprint` зелёного прогона = хеш(изменённые файлы шага + тест-файлы, которые их прогоняли). Граф импортов НЕ считаем (кросс-язык JS+PHP — дорого/хрупко). Надёжность даёт мутация P18 (сломал изменённый код — тест обязан покраснеть). Правка этих файлов после прогона меняет отпечаток → GREEN аннулируется. `criteriaGreenMatched` (`judge-gate-floor.mjs:53`, сейчас только green+id) дополняется сверкой отпечатка.

**Δ3 — Блок 5: честный двухтакт reconcile.** Убрать обещание «атомарно на исполнении» (PreToolUse-хук не видит факт исполнения; журнал пишется ПОСЛЕ allow — `enforce-supreme-gate.mjs:183`). Достижимый максимум: **пред-запись намерения в журнал ДО allow** (нет пред-записи → стена не разрешает) + **PostToolUse-сверка**, что произошло ровно записанное. W3 переформулировать на эту двухтактную модель.

### Поправки после факт-аудита (убраны призраки)

**Δ4 (переписан) — Блок 2: честная ценность подписанта.** Факт: HMAC-ключ в OS-keychain читается ЛЮБЫМ процессом того же пользователя (`receipt-key-config.mjs:37`); `node <файл>` whitelisted (`:90`) → контроллер пишет `tools/X.mjs` и читает ключ. Значит «подписант + сужение node-whitelist» **НЕ делает ключ нечитаемым** против мотивированного контроллера (он напишет новый разрешённый скрипт). Честная ценность Блока 2: (а) убирает ключ из логов/env (анти-казуальная утечка); (б) **шов под HSM/YubiKey** (v4.2) — туда встанет железо, и подделка станет реально невозможной. Согласуется с §7 (~0.5% неустранимо без железа). Блок 2 **НЕ заявляет** «защиту ключа от контроллера» — тем самым **заменяет** фразу §3 Блок 2 «нельзя прочесть ключ в своём процессе» (фактически читается через `node tools/X.mjs`). Сужение node-whitelist остаётся как анти-казуальная мера + occurrence-счётчик в payload подписанта (один зелёный нельзя пере-зачесть на два критерия), без над-обещаний.

**Δ5 — Блок 3: criterion_id = целостность, не подлинность.** id детерминирован из публичного содержимого — контроллер пересчитает. Защита от подделки = подпись подписанта на зелёном прогоне, не совпадение id. Сверка обязана быть И-обеими (id ∈ sealed И валидная подпись). Зафиксировать явно.

**Δ6 (переписан) — Блок 3/4: используем существующую лесенку, не плодим функцию.** `runGateLadder(floorSteps)` уже И-нит шаги и коротит на первом провале; `finalGate` блокирует на всём, что не явный `false`. Не вводим новый `criterionFullyProven`. Вместо этого: **четыре проверки критерия — `criteriaFromSealedPlan` (sealed-id) ∧ `criteriaGreenMatched` (green) ∧ свежий-fingerprint (Δ2) ∧ подпись-подписанта — регистрируются шагами лесенки критерий-гейта** + структурный тест «лесенка критерий-гейта = ровно эти 4 шага». Лекарство от «забыл пол» уже встроено — не дублируем.

**Δ7 — Блок 1: defense-in-depth М2.** Стена (`enforce-supreme-gate.decide`) на разрешающем пути дополнительно зовёт `classifyDestructive` (floor-порог, см. N1) и **отказывается продвигать указатель шага** на разрушительном in-plan действии. Даже при незарегистрированном floor-хуке стена не благословит снос. Малой связностью.

**Δ8 — Блок 5: манифест-самопроверка = громкий WARN, не блок.** Проблема черепах: хук не гарантирует регистрацию других хуков. Честно — WARN владельцу (созвучно `judgeHealth` → `cry:true, mode:'floor-only'`, `judge-orchestrator.mjs:57`). «Пол стоит» — сигнал, не гарантия.

**Δ9 — Блок 5: +2 CI-инварианта** к escape≠protection: (а) удаление строки из жёсткого floor-списка падает в CI без ADR (анти-усыхание пола); (б) grep-инвариант «нет regex разрушительных команд кроме `classifyDestructive`» (анти-дрейф консолидации §4).

### Новые находки факт-аудита

**N1 (важно) — `classifyDestructive` двухуровневый.** Факт: регэксп `a2CaseSelect` (`judge-orchestrator.mjs:84`) матчит **голый `migrate`**, `drop`, `delete`. Для голосов судьи безопасно (лишний голос). Но как ПОЛ (hard-block) — заблокирует обычный `php artisan migrate` → сломает деплой. Канонический `classifyDestructive(cmd)` возвращает **два уровня**: `floor:true` только для точного необратимого набора (`migrate:fresh|reset|refresh`, `db:wipe`, `push --force`/`-f`, `reset --hard`, запись секрета, запись в `~/.claude/runtime`); грубый `suspicious:true` — для голосов судьи. Пол берёт `floor`; `a2CaseSelect`/`detectHighRisk` — `suspicious`. Floor-набор **НЕ** включает голый `migrate`/`migrate:rollback`.

**N5 (дисциплина) — тест-инвариант «строгая проверка пола».** Структурный тест по всем потребителям полов: никто не использует `if (result.ok)` (truthy), только `=== true`; недоказанный/упавший пол → block. Защищает уже сделанные фиксы (`finalGate !== false`, `runGateLadder` try/catch) от регресса по всем полам разом.

### Влияние на план (фаза 6)
Стройка и адверсариальные линзы — **инлайн TDD**, без Task/суб-агентов (ограничение владельца). Самый рискованный единичный этап — сужение node-whitelist (`enforce-router-gate.mjs:90`): затрагивает все `node tools/*.mjs` → отдельный этап под smoke + явный откат.
