diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 91f73dc..71dee53 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -19,6 +19,7 @@ - **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. **ORCH-091 (индикация-only):** три корректности рендера — (1) `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (добавлены `deploy-staging`→«Deploying (staging)», `cancelled`→«Cancelled»; полнота гарантируется тестом по `stages.STAGE_TRANSITIONS`, не статичным списком — NFR-3), runtime-фолбэк для неизвестной стадии стал нейтральным (капитализированное имя) вместо «To Analyse»; (2) при откате конвейера `✅`-строки стадий ПОЗЖЕ текущей позиции (позиция — из порядка `STAGE_TRANSITIONS`, с нормализацией `deploy-staging→deploy` только в гейте подавления; `is_active_stage` не тронут) больше не рисуются; (3) строка стадии суммирует ВСЕ `agent_runs` агента (Σ cost/токены/время теми же формулами, что блок тоталов) → строгая сходимость с `SUM(agent_runs)`. Только `src/notifications.py` + тесты; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/транспорт — не тронуты. Контракт всего компонента — never raises; карточка всегда silent. **ORCH-095 (HTML-безопасность данных):** текст карточки шлётся с `parse_mode=HTML`; каждый **data**-слот (длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл, модель/эффорт, токены/стоимость) экранируется `html.escape` ровно один раз на границе рендера, **markup**-слоты (`num_html`/`link_for`/`_done_link`/`esc_title`) — нет (двойное экранирование запрещено). Устранён класс «неэкранированные данные в HTML» (литерал `<1м` от `_fmt_minutes` → Telegram `400 can't parse entities` → застывшая карточка, инцидент ORCH-093); `_fmt_minutes` по-прежнему даёт `<1м` (escape рендерит визуально идентично). Застрявшая карточка в окне авто-восстанавливается следующим рендером; `edit_telegram`/`update_task_tracker`/леджер сирот не тронуты. Детали — [internals.md](internals.md) §7, [ADR-087](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md), [ORCH-091 ADR-001](../work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md) и [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md). - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). +- **Plane write guard** (`src/plane_write_guard.py`, ORCH-117 — design, [adr-0046](adr/adr-0046-sandbox-only-plane-write-guard.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`deploy_status_guard`), закрывает дефект изоляции ORCH-114: тестовый/worktree-процесс (`python -m pytest` из worktree) с живым боевым токеном выполнил **реальную** запись в **боевой** Plane-проект («ложный Done»). Гард `decide(project_id, op, work_item_id) -> (ok, reason)` врезается в три примитива записи `plane_sync` (`update_issue_state`/`add_comment`/`_set_issue_state_direct`) сразу после `_resolve_project_id` и **до** любого сетевого шага. Активен **только в тест-процессе** (детект `"pytest" in sys.modules`/`PYTEST_CURRENT_TEST`, call-time → иммунитет к захвату `PLANE_HEADERS`/`PROJECT_ID` на импорте); боевой и staging рантаймы (`uvicorn src.main:app`, pytest в процесс не импортирован) — **no-op, byte-for-byte**. В тест-процессе **default-deny**: запись разрешена ⇔ (а) opt-in `plane_test_write_enabled` **и** (б) `project_id ∈ plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-…`); боевой проект запрещён **даже при opt-in** (allowlist sandbox-only). Второй независимый sandbox-bound слой — autouse-фикстура `tests/conftest.py::_plane_sandbox_only` (паттерн `_no_telegram`), форсящая безопасные дефолты во всех тестах; sandbox-e2e ре-энейблит opt-in после неё. **Намеренно без prod-блок kill-switch** (NFR-6: выключатель = чёрный ход; реверс — sandbox-bound opt-in; прецедент `_no_telegram`); **НЕ `*_repos`-scope** (защищает запись в любой боевой проект общего workspace, как observer-leaf `lessons`). Аудит: блок → структурный WARNING (`project_id`/`work_item`/`op`/`reason`), sandbox-allow → INFO. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — **не тронуты**. Детали — `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`. - **FS ownership detect** (`src/fs_normalize.py`, ORCH-057 — [adr-0031](adr/adr-0031-legacy-ownership-normalization.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`preflight`), закрывает пробел ORCH-040: при миграции на `user: "1000:1000"` legacy `root:root` файлы в `/repos` ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал). Три слоя: (1) **D1** — `src/git_worktree.py::ensure_worktree` классифицирует класс «нет прав» (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`) и поднимает actionable `RuntimeError` с причиной + лечащей командой (не-прав-ошибки сохраняют прежний контракт — меняется только формулировка, не факт сбоя); (2) **D2** — `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid` + TTL-кэш; (3) **D3** — best-effort вызов на старте `main.lifespan` → WARNING + Telegram при mismatch (claim **НЕ** блокируется — внятный ранний отказ даёт D1 в точке launch, знающей repo; preflight-блок отвергнут как repo-слепой → регресс enduro). Опц. `normalize()` chown'ит только при `CAP_CHOWN` (под uid 1000 — no-op; init-контейнер/root-entrypoint отвергнуты — реинтродукция root-контекста + self-deploy compose). Фактическая нормализация = **операторская процедура** под root на хосте (`INFRA.md` «Миграция uid»). Условность `applies(repo)` first: `fs_normalize_enabled` (kill-switch) + `fs_normalize_repos` (CSV, пусто → self-hosting only). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST /fs-normalize/check`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`. - **Metrics endpoint** (`src/metrics.py` + `GET /metrics`, ORCH-099 — [adr-0030](adr/adr-0030-metrics-endpoint.md)) — лёгкий **read-only** leaf-сборщик (`build_metrics() -> dict`, never-raise по разделам, паттерн `serial_gate.snapshot()`) + тонкий эндпоинт (стиль `GET /queue`). Отдаёт JSON-«сырьё» о самом орке (стадии задач / очередь jobs / agent-liveness / стоимость-токены) как **стабильный машинный контракт для sidecar F1b** (`watchdog/`, отдельная задача — наблюдатель отделён от наблюдаемого). Только чтение существующих `tasks`/`jobs`/`agent_runs` + in-memory-снапшотов (`worker.breaker`); два read-only helper'а в `db.py` (`get_running_agents`/`agent_cost_totals`). Логику мониторинга (пороги/алерты/история/Telegram) НЕ несёт — это F1b. Контракт ниже (§ «Сырьё-эндпоинт `/metrics`»). Kill-switch `metrics_endpoint_enabled` (дефолт `True`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. - **Lessons journal** (`src/lessons.py` + таблица `lessons`, ORCH-098 — реализовано, [adr-0034](adr/adr-0034-lessons-journal.md)) — машинный журнал уроков (структурированная база отклонений конвейера); шаг 1 эпика саморазвития (домен 0 «Фундамент», F2; топливо петли самообучения 8A), фундамент для будущих ретроспективщика (E2)/приоритизатора RICE (E3)/Стрим. Чистый **observer-leaf** (never-raise, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Аддитивная идемпотентная таблица `lessons`** (`CREATE TABLE IF NOT EXISTS` в `init_db()`, restart-safe) с полями контекста (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализа (`root_cause`/`suggestion`), статуса (`status`/`related_task`) и **атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже ретроспективщиком/человеком) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). **Автозапись 4 типов** (`source="auto"`, best-effort, дедуп в окне; `transient_retry` — только на исчерпании бюджета ретраев) тонкими врезками: `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`), `merge_hold` (`merge_gate._handle_merge_verify` HOLD), `transient_retry` (merge-retry/launcher transient budget-exhaustion), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан», ET-8). Эндпоинты `GET /lessons` (read-only, фильтры), `POST /lessons` (ручная запись), `POST /lessons/{id}` (update/доклассификация), + read-only ключ `lessons` в `GET /queue`. **Расхождение с гейт-шаблоном:** журнал observer-only → **НЕ скоупится по репо** (kill-switch `lessons_enabled` only, без `lessons_repos`); репо-разрез — на выборке (`repo`-колонка/фильтр), enduro не затронут (общая БД, аддитивная таблица). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт не тронуты (журнал не участвует в решении гейта). Kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`). Детали — `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`. diff --git a/docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md b/docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md new file mode 100644 index 0000000..c6ba7df --- /dev/null +++ b/docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md @@ -0,0 +1,121 @@ +--- +work_item: ORCH-117 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# adr-0046: Sandbox-only fail-closed гард записи в Plane из тест-процесса + +Сквозной (cross-cutting) ADR. Вводит инвариант **«мутирующая запись в Plane из тест/worktree-процесса +физически невозможна в боевой проект; sandbox — только под явным opt-in»** поверх **общего** +Plane-клиента `src/plane_sync.py` (три примитива записи, используемые ВСЕМИ проектами общего +инстанса) и нового тест-харнесс-инварианта `tests/conftest.py`. Детальное решение задачи — +`docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`. + +> Регистрируется как сквозной, т.к. правит **системно используемые** примитивы записи +> `update_issue_state`/`add_comment`/`_set_issue_state_direct` и вводит новый рантайм-компонент +> (leaf `src/plane_write_guard.py`), затрагивающий индикацию (слой B, ORCH-066) всех проектов. +> Кросс-каттинг с adr-0028 (deploy-status guard, ORCH-094) и adr-0009 (staging-tolerance, ORCH-061): +> оба — потребители того же `plane_sync`; гард для них — no-op в боевом/staging рантайме. + +## Статус +Proposed + +## Контекст + +Инцидент **ORCH-114**: тестовый/worktree-процесс (`python -m pytest` из worktree) выполнил +**реальную** запись в Plane против **боевого** проекта ORCH (`PATCH state=` + комментарий) — +«ложный Done» на боевой доске. Корень (сверено по коду `src/plane_sync.py`): + +1. `PLANE_HEADERS`/`PROJECT_ID` (боевой токен + боевой дефолтный проект) **захвачены на импорте** + модуля (стр. 17/57) → подмена env/токена постфактум бесполезна. +2. Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN",…)` — **no-op** в контейнере с уже + установленной боевой переменной. +3. Все мутации сходятся в **три** примитива (`update_issue_state`/`add_comment`/ + `_set_issue_state_direct`), и ни один **не** проверяет тест-контекст и легитимность целевого + проекта. + +Симметричная защита для Telegram (`tests/conftest.py::_no_telegram`) существует и работает по тому же +классу проблем («pytest на проде слал реальные сообщения»); для Plane-записи её **не было**. + +## Решение + +**Fail-closed гард на низком чокпоинте**, в момент вызова, двумя независимыми sandbox-bound слоями. + +### D1 — Рантайм-leaf `src/plane_write_guard.py` (never-raise) + +Чистый leaf (паттерн `serial_gate`/`cancel`/`deploy_status_guard`): импортирует только `config`, +лениво `db`. `decide(project_id, op, work_item_id) -> (ok: bool, reason: str)`: + +1. `not _in_test_process()` → **ALLOW** (боевой/staging рантайм — no-op, byte-for-byte). +2. `project_id` нерезолвим → **BLOCK** `ambiguous-target` (fail-closed, NFR-1). +3. `not plane_test_write_enabled` → **BLOCK** `opt-in-disabled`. +4. `project_id ∉ sandbox-allowlist` → **BLOCK** `prod-project-in-test` (sandbox-only даже при opt-in). +5. иначе → **ALLOW** `sandbox-opt-in` (audit INFO). + +Врезается в 3 примитива `plane_sync` сразу после `_resolve_project_id` и **до** любого сетевого шага; +на BLOCK — структурный аудит + `return` (ни GET, ни PATCH/POST). + +### D2 — Детект `_in_test_process()` + +`"pytest" in sys.modules or PYTEST_CURRENT_TEST` (call-time). Боевой/staging рантайм +(`uvicorn src.main:app`) pytest в свой процесс не импортирует → детект там никогда не срабатывает +(нулевая регрессия). worktree-`python -m pytest` (инцидентный путь) детектируется гарантированно. + +### D3 — Conftest-floor `tests/conftest.py::_plane_sandbox_only` + +Autouse-фикстура (паттерн `_no_telegram`/`_reset_webhook_secrets`/`_disable_*`) форсит во ВСЕХ тестах +безопасные дефолты (`plane_test_write_enabled=False`, allowlist = канонический SANDBOX id), +перекрывая любую боевую переменную из окружения. Sandbox-e2e ре-энейблит opt-in **после** autouse +(scoping реальной записи на себя). Слой независим от рантайм-leaf → двойной default-deny. + +### D4 — Реверс через opt-in, БЕЗ kill-switch (норматив) + +Единственный реверсивный регулятор — sandbox-bound opt-in `plane_test_write_enabled` (+ allowlist +`plane_test_sandbox_projects`). **Намеренно нет** prod-блок kill-switch: выключатель, обнуляющий +prod-блок в тест-процессе, был бы «чёрным ходом» (NFR-6). Прецедент — `_no_telegram` (тоже без +«разрешить»-флага). **Анти-дрейф (норматив на будущее):** не вводить общий kill-switch гарда, +переоткрывающий прод-запись из pytest. + +### D5 — Скоуп: НЕ `*_repos` + +В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate`, scope по репо, т.к. *действуют* на репо), +гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro) → скоупа по +репо нет; гейты — `_in_test_process()` + opt-in (как у observer-leaf `lessons`). + +## Инварианты (что НЕ меняется) + +`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи +(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) / +схема БД — **байт-в-байт не тронуты**. Это bugfix-изоляция клиента Plane, **не** Quality Gate и +**не** стадия. Боевой и staging рантаймы — byte-for-byte (no-op гарда). adr-0028 (deploy-status +guard) / adr-0009 (staging-tolerance) / ORCH-066 (статусная модель) в проде/стейджинге не затронуты. + +## Конфиг + +| Ключ | Env | Дефолт | +|------|-----|--------| +| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` | +| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `8c5a3025-4f9d-4190-b79f-fa06276bb27e` | + +## Последствия + +- **+** Прод-запись в Plane из pytest/worktree физически невозможна независимо от токена; ORCH-114 + закрыт у источника и стал видимым (аудит). +- **+** Нулевая регрессия боевого/staging рантайма и гейтов/схемы БД. +- **−** Детект завязан на «pytest-в-процессе» (теоретический ложноположительный риск — TR-1) и + умышленный отказ от kill-switch требует явной фиксации (TR-4). См. `10-tech-risks.md`. +- **Откат:** снять врезку гарда + autouse-фикстуру + 2 конфиг-ключа → поведение до ORCH-117 (дефект + возвращается). + +## Ссылки +- Детально: `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md` +- Риски: `docs/work-items/ORCH-117/10-tech-risks.md` +- Связанные: [adr-0028](adr-0028-terminal-window-aware-deploy-status-guard.md) (ORCH-094), + [adr-0009](adr-0009-staging-infra-tolerance.md) (ORCH-061), + [adr-0034](adr-0034-lessons-journal.md) (observer-leaf без `*_repos`) +- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py`, + `scripts/staging_check.py:283` diff --git a/docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md b/docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md new file mode 100644 index 0000000..d5c76bd --- /dev/null +++ b/docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md @@ -0,0 +1,251 @@ +--- +work_item: ORCH-117 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# ADR-001: Sandbox-only fail-closed гард записи в Plane из тест-процесса + +Work Item: **ORCH-117** — test/staging Plane writes must be sandbox-only and never mutate prod +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`** +(кросс-каттинговое: вводит новый рантайм-leaf поверх **общего** Plane-клиента `plane_sync`, +используемого ВСЕМИ проектами общего инстанса, + новый тест-харнесс-инвариант в `conftest.py`). + +## Статус +Proposed + +## Контекст + +**Инцидент (установленный факт, ORCH-114).** Тестовый/worktree-процесс выполнил РЕАЛЬНУЮ запись в +Plane против **боевого** проекта ORCH: `PATCH …/issues/dd57ad23… state=` + комментарий +«Stage: deploy → done». То есть `notify_stage_change("ORCH-114","deploy","done")`, запущенный из +pytest, смутировал боевую задачу — «ложный Done» на боевой доске (источник индикации, слой B +ORCH-066). + +**Корень (сверено по коду).** Тест/staging-процессы имеют доступ к живому боевому Plane-токену и +**ничто не принуждает** их писать только в sandbox: + +- `src/plane_sync.py:17` `PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` и `:57` + `PROJECT_ID = settings.plane_project_id or "7a79f0a9-…"` (боевой ORCH) **захватываются на импорте + модуля** → подмена env/токена постфактум бесполезна (NFR-4). +- Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN","test-token")` — **no-op** в контейнере с + уже установленной боевой переменной → тесты наследуют **реальный** токен. +- Все мутирующие записи сходятся в **три** примитива: `update_issue_state` (`httpx.patch`, стр. 861), + `add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) — и + **ни один** не проверяет тест-контекст и легитимность целевого проекта. + +**Прецедент в репозитории.** `tests/conftest.py::_no_telegram` — autouse-фикстура, глушащая +`send_telegram` во ВСЕХ тестах, ровно потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения +Славе». Симметричной защиты для **Plane-записи** не было — это пробел того же класса, реализованный +ORCH-114. Идентификатор sandbox уже зафиксирован: `scripts/staging_check.py:283` +`SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`. + +**Почему «как есть» не годится.** Устойчивость стоит на стороне тестов (надеяться, что каждый тест +замокает Plane), а не на стороне системы. Любой новый/будущий путь записи, забывший мок, снова +смутирует боевую доску. Требуется **fail-closed**-инвариант: запись в боевой проект из +тест/worktree-процесса должна быть **физически невозможна**, независимо от токена в окружении. + +## Решение + +### Сводка + +Вводим **fail-closed гард записи в Plane на низком чокпоинте** — на входе трёх примитивов записи +`plane_sync`, **в момент вызова** (не на импорте). Чистую логику держит **новый leaf +`src/plane_write_guard.py`** (never-raise, по образцу `serial_gate`/`cancel`/`deploy_status_guard`): +`decide(project_id, op, work_item_id) -> (ALLOW | BLOCK, reason)`. Гард активен **только в +тест-процессе** (детект `pytest`-в-процессе) — для боевого и staging рантайма он **no-op** +(byte-for-byte, NFR-2/NFR-3). В тест-процессе запись разрешена **исключительно** при +одновременном (а) включённом opt-in-флаге **и** (б) целевом проекте ∈ sandbox-allowlist; иначе — +блок (default-deny). Дополнительно — **независимый conftest-floor** (autouse-фикстура), который +форсит безопасные дефолты во ВСЕХ тестах (BR-4). Изменение — bugfix-изоляция: **не** Quality Gate и +**не** стадия; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт не +тронуты. + +### D1 — Точка перехвата: низкий чокпоинт в 3 примитивах `plane_sync`, на момент вызова + +Гард врезается в `update_issue_state` / `add_comment` / `_set_issue_state_direct` **сразу после** +`_resolve_project_id(...)` (локальное чтение БД) и **до** любого сетевого шага (`stage_to_state`, +`find_issue_id`, `httpx.patch/post`): + +```python +project_id = _resolve_project_id(work_item_id, project_id) +ok, reason = plane_write_guard.decide(project_id, "state", work_item_id) # op ∈ {"state","comment"} +if not ok: + plane_write_guard.audit_block(project_id, "state", work_item_id, reason) + return # никакой сети — ни GET, ни PATCH/POST +# ... обычный путь (ALLOW) +``` + +**Почему этот чокпоинт (а не обёртка `httpx` и не только autouse-фикстура):** + +- **Низкий и полный.** Все `set_issue_*`/`notify_*` сводятся к этим трём примитивам (верифицировано + BRD §6) → один гард ловит любой путь, включая будущие (тот же довод, что у `deploy_status_guard` на + входе сеттеров). +- **На момент вызова → иммунитет к захвату на импорте (NFR-4/AC-7).** `PLANE_HEADERS`/`PROJECT_ID` + захвачены литералами на импорте; гард читает контекст (тест-процесс + резолвенный `project_id`) при + каждом вызове, поэтому защита не зависит от того, какой токен в `PLANE_HEADERS`, и не опирается на + неработающий `os.environ.setdefault`. +- **До сети.** Размещение до `find_issue_id`/`stage_to_state` исключает даже «безобидный» GET в + боевой workspace из тестов. +- Обёртка над транспортом `httpx` отвергнута (см. «Альтернативы») — она ниже уровня, на котором + известны `project_id`/`work_item`/`op` для аудита, и хрупка к смене HTTP-клиента. + +### D2 — Детект тест-процесса: `pytest`-в-процессе (call-time) + +```python +def _in_test_process() -> bool: + import sys, os + return ("pytest" in sys.modules) or bool(os.environ.get("PYTEST_CURRENT_TEST")) +``` + +- **`"pytest" in sys.modules`** истинно на всём прогоне pytest (collection + выполнение) в **этом** + процессе. Боевой и staging рантаймы запускаются `uvicorn src.main:app` и **не импортируют** pytest + в свой процесс → детект там **никогда** не срабатывает (NFR-2/NFR-3, AC-5/AC-6). pytest установлен в + образе (для merge-gate/coverage re-test), но запускается **отдельным субпроцессом** `python -m + pytest` — он-то и есть worktree-тест-процесс из инцидента, и его гард **должен** ловить (✓). +- **`PYTEST_CURRENT_TEST`** — вторичный сигнал (выставляется pytest на время тела теста), добавлен как + дешёвый подтверждающий признак; основной — `sys.modules`. +- Оба читаются **в момент вызова** (NFR-4). Признак консервативный: ложноположительное срабатывание в + боевом рантайме требует, чтобы кто-то импортировал `pytest` в процесс uvicorn — чего штатный + entrypoint не делает (зафиксировано как допущение, см. `10-tech-risks.md` TR-1). + +### D3 — Решение `decide`: default-deny, sandbox-allowlist, опт-ин + +`decide(project_id, op, work_item_id) -> (bool ok, str reason)` — чистая, never-raise: + +| Шаг | Условие | Исход | reason | +|-----|---------|-------|--------| +| 1 | `not _in_test_process()` | **ALLOW** | `live-runtime` (прод/staging — no-op, NFR-2/3) | +| 2 | `project_id` пуст/None/нерезолвим | **BLOCK** | `ambiguous-target` (NFR-1: «не знаю → не пишу») | +| 3 | `not settings.plane_test_write_enabled` | **BLOCK** | `opt-in-disabled` | +| 4 | `project_id ∉ sandbox_allowlist` | **BLOCK** | `prod-project-in-test` | +| 5 | иначе | **ALLOW** | `sandbox-opt-in` (audit INFO) | + +- **Allowlist sandbox-only (BR-2, AC-3).** Шаг 4 запрещает боевой ORCH (`7a79f0a9-…`) и любой боевой + enduro-проект **даже при включённом opt-in** — opt-in лишь снимает шаг 3, allowlist остаётся + жёстким полом. +- **Fail-closed по неопределённости (NFR-1, AC-4).** Нерезолвимый целевой проект → блок (шаг 2). +- **never-raise (NFR-5).** Любое внутреннее исключение `decide` интерпретируется вызывающим примитивом + по контексту: в боевом пути это уже ALLOW (шаг 1 не достигнут — `_in_test_process` False); в + тест-пути исключение трактуется как **BLOCK** (громко, fail-closed) — дефект всплывает, а не + маскируется. (Реализация: `decide` ловит свои ошибки и в тест-контексте возвращает + `(False, "guard-error")`, в live-контексте — `(True, …)`.) + +### D4 — Kill-switch: его нет (умышленно, NFR-6/FR-7) — реверс через opt-in + +**Сознательное расхождение с конвенцией «у каждого leaf есть `*_enabled` kill-switch».** Гард, +делающий прод-запись из pytest *физически невозможной*, **не должен** поставляться с конфигом, +который её переоткрывает — это и есть «чёрный ход», запрещённый NFR-6. Прямой прецедент в репозитории: +`_no_telegram` тоже **не имеет** флага «разрешить реальный Telegram в тестах» — это безусловный +страховочный пол. + +- **Единственный реверсивный регулятор — opt-in** `plane_test_write_enabled` (default `False` = + безопасно) + allowlist `plane_test_sandbox_projects` (default = единственный SANDBOX id). Он + управляет **только sandbox-записью**; его off-состояние — безопасный дефолт, on-состояние — + sandbox-bound. «Выключить защиту» ≠ «разрешить прод из pytest»: такого перехода в дизайне **нет**. +- Рантайм-leaf **инертен в боевом рантайме** по построению (`_in_test_process()` False) → отдельный + «выключатель» для безопасности прода не нужен; leaf never-raises → не может уронить боевой путь. +- **Норматив на будущее (анти-дрейф):** не добавлять «общий kill-switch гарда», обнуляющий + prod-блок в тест-процессе — это реинтродуцирует дефект ORCH-114. Зафиксировано в `10-tech-risks.md` + (TR-4) и в сквозном adr-0046. + +### D5 — Conftest-floor: независимый default-deny во всех тестах (BR-4, FR-3) + +Autouse-фикстура `tests/conftest.py::_plane_sandbox_only` (по образцу `_reset_webhook_secrets` / +`_disable_merge_verify`) форсит безопасные дефолты для **каждого** теста через `monkeypatch`, +**перекрывая** любую боевую переменную, унаследованную из окружения контейнера: + +```python +@pytest.fixture(autouse=True) +def _plane_sandbox_only(monkeypatch): + from src import config as _cfg + monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", False, raising=False) + monkeypatch.setattr(_cfg.settings, "plane_test_sandbox_projects", + "8c5a3025-4f9d-4190-b79f-fa06276bb27e", raising=False) + yield +``` + +- С opt-in `False` гард блокирует **все** записи в тестах (и sandbox, и прод) → AC-4 default-deny. +- **Sandbox-e2e переопределяет** в собственной фикстуре *после* autouse (точно как + `test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги): `plane_test_write_enabled=True` + (+ allowlist уже содержит sandbox) → запись в SANDBOX проходит (AC-2), в прод — по-прежнему блок + (allowlist, AC-3). +- Floor **независим от рантайм-логики**: даже если рантайм-leaf по ошибке вернёт ALLOW, инвариант + «opt-in off» делает прод-запись из обычного pytest невозможной. Два слоя, оба sandbox-bound → + ни один не способен разрешить прод-запись из pytest (двойной NFR-6). + +### D6 — Конфиг-ключи + +В `src/config.py` (дефолты = безопасные): + +| Ключ | Env | Дефолт | Назначение | +|------|-----|--------|------------| +| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` | opt-in реальной записи из тест-процесса | +| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `"8c5a3025-4f9d-4190-b79f-fa06276bb27e"` | CSV allowlist sandbox-проектов | + +- **НЕ `*_repos`-scope.** В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate` *действуют* на + репо), этот гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro, + BR-2). Регуляторов scope по репо нет; единственные гейты — `_in_test_process()` (рантайм) + opt-in + (как у observer-leaf `lessons`, который тоже не скоупится по репо). Зафиксировано в adr-0046. +- `.env.example` дополняется обоими ключами с безопасными дефолтами (deliverable developer'а). + +### D7 — Аудит/наблюдаемость (BR-6, FR-5, AC-8) + +- **Блок** → структурный `logger.warning`/`error` с полями `project_id`, `work_item`, `op` + (`state`/`comment`), `reason` (`prod-project-in-test`/`opt-in-disabled`/`ambiguous-target`/ + `guard-error`). Формулировка делает инцидент класса ORCH-114 **очевидным** (не молчаливым). +- **Разрешённая sandbox-запись** → audit `logger.info` с `project_id` и `op`. +- Новый эндпоинт **не вводится** (TRZ §4): состояние гарда (флаг/allowlist) при желании дорисовывается + read-only в `GET /queue` — **необязательно**, оставлено на усмотрение developer'а. + +## Альтернативы + +- **Только autouse-фикстура в `conftest.py` (без рантайм-leaf)** — отвергнуто: не делает прод-запись + *физически невозможной* (AC-7) — любой путь, обошедший мок/фикстуру (прямой импорт `plane_sync` в + скрипте под pytest, sandbox-e2e с опечаткой проекта), снова смутирует прод. Нужен рантайм-floor на + момент вызова. Фикстура остаётся как **дополнительный** независимый слой (D5), не единственный. +- **Обёртка/монки над `httpx`-транспортом** — отвергнуто: уровень ниже, чем известны + `project_id`/`work_item`/`op` (хуже аудит); хрупко к смене HTTP-клиента; ловит и легитимные GET. + Низкий чокпоинт в примитивах точнее и стабильнее. +- **Подмена `settings.plane_api_token`/env на тестовый токен** — отвергнуто прямо BRD/NFR-4: + `PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте → подмена постфактум бесполезна; `setdefault` + no-op в проде. Не лечит корень. +- **Гонять тесты в sandbox-проекте по дефолту (сменить дефолтный `PROJECT_ID`)** — отвергнуто: не + fail-closed (живой токен + забытый мок всё равно может адресовать прод явным `project_id`); не + отличает прод от sandbox по *намерению*. +- **Конвенциональный `plane_write_guard_enabled` kill-switch** — отвергнуто (D4): его off-состояние + было бы «чёрным ходом» к прод-записи из pytest (NFR-6). Реверс обеспечивает opt-in. + +## Последствия + +- **+** Прод-запись в Plane из любого pytest/worktree-процесса **физически невозможна** независимо от + токена (AC-1/AC-7); инцидент класса ORCH-114 закрыт у источника и стал **видимым** (аудит). +- **+** Боевой и staging рантаймы — **байт-в-байт** (no-op гарда, `_in_test_process()` False); + `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД не тронуты (NFR-2/NFR-3, AC-5/AC-6). +- **+** Два независимых sandbox-bound слоя (рантайм-leaf + conftest-floor) → нет одиночной точки, чьё + выключение переоткрывает прод (NFR-6). +- **+** Sandbox-e2e сохранён (opt-in + allowlist, AC-2); `scripts/staging_check.py` Block C + работоспособен. +- **−** Детект тест-процесса завязан на «pytest-в-процессе» → теоретический ложноположительный риск, + если кто-то импортирует `pytest` в боевой uvicorn-процесс (не делает штатный entrypoint). Митигейшн: + TR-1, консервативный признак, аудит-видимость; в худшем случае под opt-in остаётся sandbox-запись. +- **−** Намеренный отказ от kill-switch расходится с привычной конвенцией → требует явной фиксации, + чтобы будущий агент не «добавил выключатель» (D4-норматив, TR-4, adr-0046). +- **Откат:** удалить врезку гарда из 3 примитивов + autouse-фикстуру + 2 конфиг-ключа → поведение + байт-в-байт до ORCH-117 (дефект возвращается). Частичный «мягкий» откат (oct-in `True` глобально) — + **запрещён** как небезопасный (вернёт прод-риск только при условии allowlist; всё равно + sandbox-bound). + +## Ссылки +- BRD: `docs/work-items/ORCH-117/01-brd.md` +- TRZ: `docs/work-items/ORCH-117/02-trz.md` +- Acceptance: `docs/work-items/ORCH-117/03-acceptance-criteria.md` +- Tech-risks: `docs/work-items/ORCH-117/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md` +- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py` (`_no_telegram`), + `scripts/staging_check.py:283`, `src/deploy_status_guard.py` (образец leaf), `src/config.py` +- Прецедент-инвариант: `_no_telegram` (autouse safety-floor), `docs/_standards/TRACEABILITY.md` diff --git a/docs/work-items/ORCH-117/10-tech-risks.md b/docs/work-items/ORCH-117/10-tech-risks.md new file mode 100644 index 0000000..5d4d4c6 --- /dev/null +++ b/docs/work-items/ORCH-117/10-tech-risks.md @@ -0,0 +1,41 @@ +--- +work_item: ORCH-117 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-117 — sandbox-only fail-closed изоляция записи в Plane + +Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации решения ADR-001 и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Ложноположительный детект тест-процесса** в боевом/staging рантайме (`pytest` каким-то образом импортирован в процесс uvicorn) → блокировка легитимной боевой/sandbox записи (молчаливая потеря Plane-индикации, слой B ORCH-066). | Низ. | Сред. | Штатный entrypoint `uvicorn src.main:app` **не** импортирует `pytest`; merge-gate/coverage гоняют pytest **отдельным субпроцессом** (его блок легитимен). Признак консервативный (`sys.modules`+`PYTEST_CURRENT_TEST`), читается на момент вызова. Аудит-WARNING делает любой блок видимым (BR-6) → ложный блок в проде немедленно заметен, а не молчалив. Зафиксированное допущение: «прод-процесс не импортирует pytest». | +| TR-2 | **Ложноотрицательный детект** (worktree-тест-процесс не распознан как pytest) → дефект ORCH-114 остаётся. | Низ. | Выс. | Инцидентный путь (worktree `python -m pytest`) **гарантированно** имеет `pytest` в `sys.modules`. Двойной слой: даже при сбое рантайм-leaf conftest-floor (D5) держит default-deny. Обязательный регресс-тест AC-1/TC-01 (красный до фикса) доказывает покрытие именно инцидентного пути. | +| TR-3 | **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`, стр. 17/57): размещение гарда не там → ложное чувство защиты (NFR-4). | Низ. | Выс. | Архитектурно жёстко: гард — на момент **вызова** примитива, до сети, не зависит от токена/`os.environ.setdefault` (ADR-001 D1/D2). AC-7 проверяет это буквально. | +| TR-4 | **Kill-switch как чёрный ход:** будущий агент «добавляет общий выключатель гарда», который в off-состоянии переоткрывает прод-запись из pytest (NFR-6). | Сред. | Выс. | Дизайн **умышленно без** prod-блок kill-switch (ADR-001 D4): реверс — только sandbox-bound opt-in. Норматив зафиксирован в ADR-001, adr-0046 и `10-tech-risks` как анти-дрейф; прецедент `_no_telegram` (тоже без «разрешить» флага). Reviewer ловит реинтродукцию выключателя как finding ≥P1. | +| TR-5 | **Регрессия существующего сьюта:** autouse-фикстура `_plane_sandbox_only` ломает тесты, которые ждали реальную/иную запись или ассертили на мок-вызов. | Низ. | Сред. | Большинство тестов уже **мокируют** `plane_*`/`add_comment` (TRZ §7) → реальной записи и так нет; гард лишь делает это гарантией по умолчанию. Фикстура форсит лишь безопасные дефолты (opt-in off), не подменяет сами примитивы. AC-11 (полный регресс зелёный) — обязательный гейт. | +| TR-6 | **Sandbox-e2e ложно блокируется** (opt-in не доезжает / порядок фикстур). | Низ. | Сред. | Прецедент уже отработан в репозитории: `test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги **после** autouse. AC-2 проверяет реальную sandbox-запись под opt-in; `staging_check.py` Block C — smoke. | +| TR-7 | **Утечка GET до гарда:** даже без PATCH/POST примитив мог бы сходить в боевой workspace `find_issue_id`/`stage_to_state`. | Низ. | Низ. | Гард размещён **до** любого сетевого шага (сразу после локального `_resolve_project_id`) — ни GET, ни мутации (ADR-001 D1). | +| TR-8 | **Кросс-каттинг с ORCH-066/094/061:** гард меняет поведение общего `plane_sync`, потенциально задевая deploy-status guard (ORCH-094), staging-tolerance (ORCH-061), статусную модель (ORCH-066). | Низ. | Сред. | Гард — no-op в боевом/staging рантайме (`_in_test_process()` False) → ORCH-094/061/066 в проде/стейджинге **не затронуты**. В тестах те фичи и так под своими дефолтами/моками. Маркер-инвариант не ломается (правка примитивов аддитивна, не трогает машинные ключи). | + +## Сводный вывод + +Доминирующий класс — **корректность детекта тест-процесса** (TR-1/TR-2) и **анти-дрейф kill-switch** +(TR-4). Все три закрываются архитектурно: консервативный признак «pytest-в-процессе» на момент +вызова + двойной независимый sandbox-bound слой (рантайм-leaf + conftest-floor) + обязательный +регресс-тест инцидентного пути (AC-1). Остаточный риск для прод-конвейера (self-hosting) — **низкий**: +гард инертен в боевом и staging рантайме по построению и never-raises, поэтому не способен ни уронить +конвейер, ни заблокировать легитимную боевую запись; худший реалистичный исход (ложный блок в проде) +требует несуществующего в штатном entrypoint импорта pytest и был бы немедленно виден через аудит. + +**Эскалация не требуется:** решение аддитивно, в границах принципов (Docker/SQLite/leaf-pattern/ +never-raise), не трогает `STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД, не вводит новую стадию/QG/компонент +инфраструктуры. Лейбл `arch:major-change` не ставится; возврат в анализ не нужен.