docs(router-mentor): E/G spec — 4 правки безопасности после adversarial-анализа

Цепочка audit-context-building/sharp-edges/variant-analysis. P1 узкий stripPastedContext (fenced+blockquote only, не reuse stripQuotedContext — сохранить FP-смещение гейта). P2 counting-replacers по конвейеру sanitize (count==редакции). P4 escapeCell anti-injection в STATUS.md. P5 пиннинг документируемого инварианта. Жёсткие стены М2/М5/М4/М6 не затронуты.
This commit is contained in:
Дмитрий
2026-06-08 19:40:34 +03:00
parent 7a6bb28259
commit 4e1aef53c4
@@ -2,6 +2,7 @@
**Дата:** 2026-06-08 · **Ветка:** `worktree-brainrepo` · **Кодовая фраза эпика:** «роутер-наставник».
**Статус:** УТВЕРЖДЁН владельцем (2026-06-08). Источник — реестр хвостов `2026-06-08-router-mentor-loose-ends-registry.md`, блоки E (Doc-gaps/косметика) + G (периферия мозга).
**Анализ-фаза (adversarial, до плана):** прогнан цепочкой `audit-context-building → sharp-edges → variant-analysis` на «не пробьёт ли дыр в М1–М7». Вывод: жёсткие стены (М2/М5/М4/М6) не затронуты; единственный затронутый гейт — observer routing-gate (Машина 0). Внесены 4 правки безопасности: P1 узкий `stripPastedContext` (SE-1, сохранить FP-смещение), P2 counting-replacers по конвейеру sanitize (SE-2, `count==редакции`), P4 `escapeCell` (SE-3, anti-injection в STATUS.md), P5 пиннинг документируемого инварианта (SE-4).
## Цель
@@ -17,27 +18,33 @@
### 1. G — 4-й FP-класс routing-gate (`tools/observer-routing-detector.mjs`)
**Дефект:** `detectMethodDirected` ([:46](../../../tools/observer-routing-detector.mjs)) флагует `/<node>` где угодно в тексте, включая процитированный/вставленный фрагмент (`` `/brain-retro` `` в backticks, «…используй /node…» в кавычках, строки-цитаты `> …`). Цитата → ложный `directed:true`. Зеркало известного урока `stripQuotedContext`.
**Дефект:** `detectMethodDirected` ([:46](../../../tools/observer-routing-detector.mjs)) флагует `/<node>` где угодно в тексте, включая вставленный фрагмент (PASTED-транскрипт/документ). Цитата → ложный `directed:true`. Этот символ кормит **живой** Stop-gate `routingGateDecision` ([observer-stop-hook.mjs:350](../../../tools/observer-stop-hook.mjs)) — не только телеметрию.
**Подход:** добавить чистую предобработку `stripQuotedContext(text)` — снять inline-code (одиночные/тройные backticks), guillemets `«…»`, двойные кавычки, markdown-blockquote-строки (начинающиеся с `>`) — **до** `/node`-скана и directive-verb-окна. Применяется и к `/`-форме, и к verb-window-форме.
**Граница направления отказа (audit-context + sharp-edges SE-1):** текущий дизайн «conservative-broad» намеренно ошибается в сторону FP (`directed:true` дёшево — контроллер просто добавляет routing-тег). Под-детекция (FN) = тихая потеря governance-сигнала. Поэтому strip обязан быть **узким**, сохраняя FP-смещение для inline-форм.
**Variant-факт:** существующий `stripQuotedContext` ([enforce-rationalization-audit.mjs:51-59](../../../tools/enforce-rationalization-audit.mjs)) стрипает fenced **+ inline-backticks + кавычки**, но **НЕ** blockquote `>`. Его **нельзя reuse** для гейта: он снял бы inline-директиву `использу­й \`/x\`` → FN. При этом реальный FP (pasted-транскрипт) обычно — `>`-цитата, которую существующая функция не снимает.
**Подход:** новая отдельная чистая функция `stripPastedContext(text)` в `observer-routing-detector.mjs` — снимает **только** (а) fenced-блоки ` ```…``` ` и (б) markdown-blockquote-строки (начинающиеся с `>`/`&gt;`). **НЕ** трогает inline-backticks, кавычки, guillemets. Применяется к тексту **до** `/node`-скана и directive-verb-окна (обе формы). Docstring явно фиксирует расхождение с `stripQuotedContext` (противоположное безопасное направление: тот FN-safe, этот FP-safe).
**Приёмка:**
- `` `/brain-retro` `` (в backticks) → `directed:false`.
- «он написал: "запусти brain-retro"» (в кавычках/цитате) → `directed:false`.
- `/brain-retro` внутри fenced-блока`directed:false`.
- строка-цитата `> используй brain-retro``directed:false`.
- inline `сделай через \`/brainstorming\`` (НЕ в fence/цитате) → `directed:true` (сохраняем FP-смещение — это НЕ FP-баг, это safe-direction).
- голый `/brain-retro` в начале промпта → `directed:true` (без регрессии).
- «запусти brain-retro» (не в кавычках) → `directed:true` (без регрессии).
- «запусти brain-retro» (обычная проза) → `directed:true` (без регрессии).
- существующие тесты `observer-routing-detector.test.mjs` — зелёные.
### 2. G — PII double-count перекрытий (`tools/observer-pii-filter.mjs`)
**Дефект:** `countString` ([:66-73](../../../tools/observer-pii-filter.mjs)) прогоняет КАЖДЫЙ паттерн независимо по сырой строке. Токен, матчащий два паттерна (например `sk-…` внутри `Bearer sk-…`), считается дважды → телеметрия `.pii-counters.json` завышена. Сам `sanitizeString` корректен (последовательное потребление через `.replace`).
**Подход:** считать по тому же последовательному потреблению, что и `sanitizeString` — заменять каждый паттерн на sentinel в том же порядке (специфичные первыми) и считать число замен на каждом шаге. Перекрывающийся спан засчитывается один раз — самым специфичным паттерном (первым по порядку).
**Подход (sharp-edges SE-2 — count == реально заредактированное):** считать инструментированием **того же** replace-конвейера, что и `sanitizeString` (тот же порядок регексов, специфичные первыми), а **НЕ** параллельным матчером по сырой строке. Каждый `.replace(re, …)` получает counting-replacer-функцию, инкрементящую счётчик паттерна на фактическую замену; следующий паттерн работает уже по строке после предыдущей замены (перекрытие потреблено первым/самым специфичным). По конструкции `count` равен числу реальных редакций sanitize — под-счёт реальной утечки невозможен (под-счёт хуже пере-счёта для security-телеметрии). `sanitize()` / `sanitizeString()` **не трогаем** — только `countString` переписывается на общий конвейер (вынести общий шаг replace+count, чтобы санитайз и счёт не разъехались).
**Приёмка:**
- `Bearer sk-AAAAAAAAAAAAAAAAAAAA` → суммарный счёт по этому спану = 1 (по первому матчнувшему паттерну в порядке), а не 2.
- строка с одиночными, непересекающимися токенами → счётчики без изменений.
- `sanitizeWithCount(...).sanitized` остаётся byte-identical прежнему (санитайз не трогаем).
- `Bearer sk-AAAAAAAAAAAAAAAAAAAA` → суммарный счёт по этому спану = 1 (первый матчнувший паттерн в порядке конвейера), а не 2.
- две РАЗНЫЕ непересекающиеся утечки рядом (`a@b.co +71234567890`) → счёт 2 (не «съесть» вторую).
- строка с одиночными непересекающимися токенами → счётчики без изменений.
- `sanitizeWithCount(...).sanitized` остаётся byte-identical прежнему (санитайз не тронут).
- существующие тесты `observer-pii-filter.test.mjs` — зелёные.
### 3. G — release-class `\bcommit\b` (`tools/observer-transcript-parser.mjs:215`)
@@ -58,8 +65,11 @@
**Подход:** при непустых `recentEscapes`/`recentBlocks` рендерить компактную таблицу (время · действие · причина) под строкой-счётчиком. Пустые массивы → текущий вывод byte-identical. Чистая функция; `main()` продолжает передавать `[]` (live-источник — B-блок, граница соблюдена).
**Guard (sharp-edges SE-3 — injection в STATUS.md):** поля событий идут в markdown-таблицу. Поле с `|`/переводом строки сломало бы таблицу или **подделало строки обороны** (ложное «✅ зарегистрирован» → ложное чувство защиты у владельца). Готового escape-helper'а в `status-md-generator.mjs` нет (variant-факт). Добавить маленький `escapeCell(v)`: `String(v)` → заменить `|``\|`, `\r?\n`` `, cap длины (например 120 символов с `…`). Применять к каждому полю detail-таблицы. Делаем safe-by-construction **сейчас**, до того как B-блок подведёт live-источник.
**Приёмка:**
- given sample-массив escape/block-событий → деталь-таблица отрендерена (колонки время/действие/причина).
- поле `reason` с `|` и `\n` → в выводе экранировано (`\|`, без переноса), таблица цела, лишних строк нет.
- `recentEscapes=[]`, `recentBlocks=[]` → вывод byte-identical текущему (счётчики-строка).
- существующие тесты `status-md-generator.test.mjs` — зелёные.
@@ -67,11 +77,11 @@
**Дефект:** node-graph docstring «Потребляет registry от loadRegistry» неточен (функция берёт plain-registry-объект, не обязательно «от loadRegistry»); reviewer prompt-caching `cache_control` — намеренный no-op на Opus (<4096-токенный префикс), это не задокументировано в коде.
**Подход:** уточнить формулировки. Чтобы честно пройти TDD-gate на comment-only правке `.mjs` — к каждой косметике приколотить 1 пиннинг-ассерт (RED→GREEN): node-graph — `buildNodeGraph` корректно строит граф из plain-объекта `{nodes, chains}` (документирует развязку); reviewer — structured-prompt возвращает непустой `system`-блок (документирует, что caching применяется к нему). Если TDD-gate не срабатывает на comment-only edit — править напрямую без пиннинга.
**Подход:** уточнить формулировки. Чтобы честно пройти TDD-gate на comment-only правке `.mjs` — к каждой косметике приколотить 1 пиннинг-ассерт (RED→GREEN), который пинит **именно документируемый инвариант, не трюизм** (sharp-edges SE-4): node-graph — `buildNodeGraph` строит корректный граф из plain-литерала `{nodes, chains}` (документирует развязку от `loadRegistry`); reviewer — structured-prompt возвращает объект с непустым `system`-блоком (документирует, к чему применяется caching). **Variant-факт:** существующие тесты уже зовут `buildNodeGraph(REG)` с plain-объектом — поэтому новый тест именуется по claim'у («accepts plain registry literal, not loadRegistry-bound»), а не дублирует покрытие случайно. Если TDD-gate не срабатывает на comment-only edit — править напрямую без пиннинга.
**Приёмка:**
- docstring/комментарий уточнён в обоих файлах.
- пиннинг-ассерты GREEN (или, при отсутствии срабатывания gate, чистая comment-правка).
- пиннинг-ассерты GREEN и пинят документируемый инвариант (не `expect(true).toBe(true)`-трюизм); либо, при отсутствии срабатывания gate, чистая comment-правка.
### 6. E — DOC-1 escape-awareness note (M7 design spec + loose-ends registry)