diff --git a/.env.example b/.env.example index 701771a..4bd4d96 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,19 @@ ORCH_PLANE_BOT_REVIEWER= ORCH_PLANE_BOT_TESTER= ORCH_PLANE_BOT_DEPLOYER= ORCH_PLANE_BOT_STREAM= +# ORCH-117: sandbox-only fail-closed guard for Plane WRITES from a test/worktree +# process (regression of ORCH-114, where pytest mutated a live prod board issue). +# In the live runtime (uvicorn, no pytest) the guard is a no-op; in a test process +# it BLOCKS every Plane write unless BOTH the opt-in is true AND the target project +# is in the sandbox allowlist. Defaults are SAFE (default-deny): leave both as-is. +# ORCH_PLANE_TEST_WRITE_ENABLED -> opt-in for REAL Plane writes from a test process. +# false (default) = no test may write to Plane. NOT a kill-switch for the prod +# block: even true, only the sandbox allowlist below is writable (a prod write +# from pytest stays impossible). +# ORCH_PLANE_TEST_SANDBOX_PROJECTS -> CSV allowlist of sandbox project ids the +# opt-in may write to. Default = the single SANDBOX project; empty = none. +ORCH_PLANE_TEST_WRITE_ENABLED=false +ORCH_PLANE_TEST_SANDBOX_PROJECTS=8c5a3025-4f9d-4190-b79f-fa06276bb27e # Telegram live-tracker / alerts (empty -> notifications are logged, not sent). ORCH_TELEGRAM_BOT_TOKEN= ORCH_TELEGRAM_CHAT_ID= diff --git a/CHANGELOG.md b/CHANGELOG.md index 848e112..3460e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Sandbox-only fail-closed изоляция записи в Plane из тест-процесса** (ORCH-117, `fix`, bug→escalate full-cycle): закрыт корневой класс инцидента **ORCH-114** — тест/worktree-процесс выполнил РЕАЛЬНУЮ запись (`PATCH …/issues/… state=` + комментарий «Stage: deploy → done») против **боевого** Plane-проекта, т.к. тест/staging-процессы наследуют живой боевой Plane-токен (`PLANE_HEADERS`/`PROJECT_ID` захвачены литералами **на импорте** — подмена env/токена постфактум бесполезна, NFR-4), и **ничто** не принуждало их писать только в sandbox. Симметрия прецеденту `tests/conftest.py::_no_telegram` (autouse-глушилка Telegram «pytest на проде слал реальные сообщения») — для Plane-**записи** такой защиты не было. Аддитивно, never-raise в боевом пути; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и имена `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты** (это изоляция клиента Plane, **не** Quality Gate и **не** стадия). Новый чистый leaf `src/plane_write_guard.py` (`decide(project_id, op, work_item) -> (ALLOW|BLOCK, reason)`, по образцу `deploy_status_guard`/`serial_gate`) врезан в **3 примитива записи** `plane_sync` (`update_issue_state`/`add_comment`/`_set_issue_state_direct`) **на момент вызова** — сразу после локального `_resolve_project_id` и **до** любого сетевого шага (ни GET, ни PATCH/POST). Гард активен **только в тест-процессе** (детект `"pytest" in sys.modules` / `PYTEST_CURRENT_TEST`); боевой и staging рантаймы (`uvicorn src.main:app`, без pytest в процессе) — строгий **no-op** (NFR-2/NFR-3). В тест-процессе запись разрешена **только** при одновременном (а) opt-in `plane_test_write_enabled=True` **и** (б) целевом проекте ∈ sandbox-allowlist `plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-…`); иначе — default-deny; нерезолвимый проект → блок (fail-closed, NFR-1); боевой проект запрещён **даже при opt-in** (allowlist sandbox-only). Второй независимый sandbox-bound слой — autouse-floor `tests/conftest.py::_plane_sandbox_only` (opt-in OFF для всего сьюта, по образцу `_no_telegram`/`_disable_*`); sandbox-e2e ре-энейблит opt-in в своей фикстуре поверх floor. **Умышленно БЕЗ kill-switch прод-блока** (NFR-6/FR-7/anti-drift): выключателя, переоткрывающего прод-запись из pytest, нет — единственный реверс — sandbox-bound opt-in. Аудит: блок → громкий структурный ERROR (`project_id`/`work_item`/`op`/`reason` — делает инцидент класса ORCH-114 очевидным), разрешённая sandbox-запись → INFO. Новые ключи `ORCH_PLANE_TEST_WRITE_ENABLED` (дефолт `false`) / `ORCH_PLANE_TEST_SANDBOX_PROJECTS` (дефолт = SANDBOX id) с безопасными дефолтами; `scripts/staging_check.py` Block C (E2E в SANDBOX) — отдельный процесс с собственными httpx-вызовами, гардом не затронут. Покрытие — `tests/test_orch117_plane_write_isolation.py` (TC-01 — обязательный регресс ORCH-114: красный до врезки, зелёный после; TC-02…TC-14). ADR: `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`, сквозной `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`. - **Ownership-lease для side-effectful переходов стадий + умное восстановление при старте** (ORCH-114, `fix`, bug→escalate full-cycle): закрыт **корневой класс** инцидент-цепочки ORCH-110/111/112/113 — у side-effectful переходов стадий не было единого владения. `advance_stage` ре-ентерабельна и пишет стадию «голым» `UPDATE … WHERE id=?` (без compare-and-swap), а ≥5 акторов (монитор / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) входят в один переход независимо → конкурентный или после-рестартовый повторный вход **дважды** применял необратимые эффекты (merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя) и давал **противоречие rollback↔done** (инцидент ORCH-111, job 1914 / PR #130). Два комплементарных слоя, оба аддитивные, под единым kill-switch, never-raise: **(1) durable transition-lease** (новая таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион (второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе — предотвращение, не починка постфактум); **(2) expected-stage CAS** (`update_task_stage_cas`) — на ЗАПИСИ стадии (проигравший гонку — аборт без побочных эффектов), что закрывает и **6 путей записи стадии в обход `advance_stage`** (gitea×5 + plane rollback). Liveness владельца = `owner_pid` + `owner_boot_id` (НЕ heartbeat: блокирующий 900s merge re-test не может бить heartbeat — довод самого ORCH-113), что делает рестарт-recovery бесплатным (новый процесс → новый boot-id → все прежние lease мгновенно устаревшие → реклеймятся). Lease без собственного TTL: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) → сквозной бюджет ORCH-065/109/110/113 не тронут. `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна аддитивная таблица, без epoch-колонки на `tasks`). Скоуп self-hosting (`transition_lease_repos=""` → только `orchestrator`; enduro не затронут); kill-switch `ORCH_TRANSITION_LEASE_ENABLED=false` → CAS вырождается в прежний безусловный `update_task_stage`, lease инертен → поведение байт-в-байт до ORCH-114. ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. - **Leaf `src/transition_lease.py` (новый, чистый never-raise):** по образцу `serial_gate`/`coverage_gate`/`finalizer_liveness` (импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`; НЕ импортирует `stage_engine`/`launcher`) — `applies(repo)` / `acquire(task_id, owner, run_id, stage)` (атомарный rowcount-guard `INSERT … ON CONFLICT DO NOTHING` после очистки stale-строки) / `is_held_by_live_owner(task_id)` (fail-closed → defer на сомнении) / `release(task_id, force=False)` (holder-aware по boot) / `reclaim_if_stale` / `recover_on_startup` / `commit_stage_cas(task_id, expected, new, repo)` (flag-off → unconditional `update_task_stage`; flag-on → CAS) / `snapshot()`. - **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на любом исходе, включая исключение/откат); **rollback-записи side-effectful под-гейтов** (`_handle_merge_gate_rollback`/`_handle_security_gate`/`_handle_coverage_gate`/`_handle_image_freshness`) пишут `development` через тот же CAS (общий хелпер `_rollback_stage_cas`, ADR-001 D4: защита rollback↔done — под держимым lease это единственный владелец, проигранный CAS → аборт без side-effects, не слепой перетир `done`); job-reaper `_finalizer_owns` обобщён с процесс-локального ORCH-113 (Tier-2/`deploy-staging`) на **durable cross-path** lease (defer при живом владельце; Tier-3 backstop игнорирует маркер → bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=`. Покрытие — `tests/test_orch114_transition_ownership.py` (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14 + регресс CAS на in-region rollback). Флаги (`config.py`, дефолт = боевое): `transition_lease_enabled` (env `ORCH_TRANSITION_LEASE_ENABLED`), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`). diff --git a/CLAUDE.md b/CLAUDE.md index b24be1c..f23bbc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -366,6 +366,51 @@ lease **не консультирует** (fail-open, очередь репо н `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. +## Sandbox-only fail-closed изоляция записи в Plane (ORCH-117) +Закрыт корневой класс инцидента **ORCH-114**: тест/worktree-процесс выполнил РЕАЛЬНУЮ запись +(`PATCH …/issues/… state=` + комментарий «Stage: deploy → done») против **боевого** +Plane-проекта, т.к. тест/staging-процессы наследуют живой боевой Plane-токен +(`PLANE_HEADERS`/`PROJECT_ID` захвачены литералами **на импорте** `plane_sync` — подмена env/токена +постфактум бесполезна, NFR-4) и **ничто** не принуждало их писать только в sandbox. Прямой +прецедент — `tests/conftest.py::_no_telegram` (autouse-глушилка «pytest на проде слал реальные +Telegram-сообщения»); симметричной защиты для Plane-**записи** не было. **Инвариант:** запись в +боевой Plane-проект из любого pytest/worktree-процесса **физически невозможна** независимо от +токена. Аддитивно, never-raise в боевом пути; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и +имена `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты** (это изоляция клиента +Plane, **не** Quality Gate и **не** стадия). +- **Чокпоинт (D1):** новый чистый leaf `src/plane_write_guard.py` (`decide(project_id, op, + work_item) -> (ALLOW|BLOCK, reason)`, never-raise, по образцу `deploy_status_guard`/`serial_gate`/ + `cancel`) врезан в **3 примитива записи** `plane_sync` (`update_issue_state` / `add_comment` / + `_set_issue_state_direct`) **на момент вызова** — сразу после локального `_resolve_project_id` и + **до** любого сетевого шага (ни GET, ни PATCH/POST). Все `set_issue_*`/`notify_*` сводятся к этим + трём примитивам → один гард ловит любой путь, включая будущие. +- **Детект тест-процесса (D2):** `"pytest" in sys.modules` ∨ `PYTEST_CURRENT_TEST` (на момент + вызова). Боевой и staging рантаймы — `uvicorn src.main:app`, pytest в процесс **не** импортируют → + гард там строгий **no-op** (NFR-2/NFR-3); worktree `python -m pytest` (инцидентный путь) + гарантированно имеет pytest в `sys.modules` → ловится. +- **Решение (D3):** default-deny. Запись из тест-процесса разрешена ⇔ одновременно (а) opt-in + `plane_test_write_enabled=True` **и** (б) целевой проект ∈ sandbox-allowlist + `plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-4f9d-4190-b79f-fa06276bb27e`). + Нерезолвимый/пустой проект → блок (fail-closed, NFR-1). Боевой проект запрещён **даже при opt-in** + (allowlist sandbox-only). Внутренняя ошибка `decide` в тест-контексте → fail-CLOSED (`guard-error`). +- **Второй слой (D5):** независимый autouse-floor `tests/conftest.py::_plane_sandbox_only` форсит + opt-in OFF для **всего** сьюта (по образцу `_no_telegram`/`_disable_*`); sandbox-e2e ре-энейблит + opt-in в своей фикстуре поверх floor. Два sandbox-bound слоя → нет одиночной точки, чьё выключение + переоткрывает прод. +- **Умышленно БЕЗ kill-switch прод-блока (D4, NFR-6/FR-7, anti-drift):** выключателя, + переоткрывающего прод-запись из pytest, **нет** — единственный реверс — sandbox-bound opt-in. Не + добавлять «общий kill-switch гарда» (реинтродуцирует дефект ORCH-114; reviewer ловит как ≥P1). +- **Аудит (D7):** блок → громкий структурный ERROR (`project_id`/`work_item`/`op`/`reason`: + `prod-project-in-test`/`opt-in-disabled`/`ambiguous-target`/`guard-error`); разрешённая + sandbox-запись → INFO. **Флаги** (`config.py`, дефолты безопасные): `plane_test_write_enabled` + (env `ORCH_PLANE_TEST_WRITE_ENABLED`, дефолт `False`), `plane_test_sandbox_projects` (env + `ORCH_PLANE_TEST_SANDBOX_PROJECTS`, CSV). `scripts/staging_check.py` Block C (E2E в SANDBOX) — + отдельный процесс с собственными httpx-вызовами, гардом не затронут. Покрытие — + `tests/test_orch117_plane_write_isolation.py` (TC-01 — обязательный регресс ORCH-114: красный до + врезки, зелёный после; TC-02…TC-14). Детали — + `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`, сквозной + `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`. + ## Машинный журнал уроков (ORCH-098) Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в **машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 91f73dc..647c840 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 — реализовано, [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`) через тонкий хелпер `_guard_allows_write` сразу после `_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** (live-path возвращает ALLOW ДО любого try-блока → гард не может уронить боевую запись). В тест-процессе **default-deny**: запись разрешена ⇔ (а) opt-in `plane_test_write_enabled` **и** (б) `project_id ∈ plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-…`); боевой проект запрещён **даже при opt-in** (allowlist sandbox-only); нерезолвимый/пустой проект → блок (fail-closed); внутренняя ошибка `decide` в тест-контексте → fail-CLOSED. Второй независимый 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`). Аудит: блок → громкий структурный ERROR (`project_id`/`work_item`/`op`/`reason`), sandbox-allow → INFO. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — **не тронуты**. Покрытие — `tests/test_orch117_plane_write_isolation.py` (TC-01 обязательный регресс ORCH-114). Детали — `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/operations/INFRA.md b/docs/operations/INFRA.md index 1324421..c0efd19 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -141,6 +141,8 @@ watchdog'а: **watchdog сигналит, pruner убирает**. | `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API | | `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается | | `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane | +| `ORCH_PLANE_TEST_WRITE_ENABLED` | ORCH-117: opt-in реальной записи в Plane из **тест-процесса** (дефолт `false` = default-deny). НЕ kill-switch прод-блока: даже `true` пишет только в sandbox-allowlist (прод-запись из pytest невозможна). В боевом/staging рантайме гард — no-op | +| `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | ORCH-117: CSV-allowlist sandbox-проектов, куда opt-in разрешает запись из тестов (дефолт = единственный SANDBOX `8c5a3025-…`; пусто → ни один проект из тестов не пишется) | | `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC | | `ORCH_CLAUDE_BIN` | путь к claude CLI | | `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) | @@ -224,6 +226,18 @@ watchdog'а: **watchdog сигналит, pruner убирает**. **Что изолировано (безопасно):** - Staging (8501) — отдельная БД (`./data/staging`), отдельный реестр (`ORCH_PROJECTS_JSON` = только sandbox). Прод-проекты не видит. - Репозитории разделены, изоляция веток через git worktree (ORCH-2). +- **Запись в Plane из тест-процесса — sandbox-only fail-closed (ORCH-117).** Тест/worktree-процесс + наследует живой боевой Plane-токен (`PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте `plane_sync`); + раньше **ничто** не мешало pytest смутировать боевую доску (инцидент ORCH-114 — «ложный Done»). + Теперь leaf `src/plane_write_guard.py` врезан в 3 примитива записи `plane_sync` + (`update_issue_state`/`add_comment`/`_set_issue_state_direct`) и **в тест-процессе** (детект + `pytest`-в-процессе) блокирует запись по умолчанию; разрешена только при opt-in + `ORCH_PLANE_TEST_WRITE_ENABLED=true` **И** целевом проекте ∈ `ORCH_PLANE_TEST_SANDBOX_PROJECTS` + (sandbox-only — боевой проект запрещён даже при opt-in). Боевой и staging рантаймы + (`uvicorn src.main:app`, без pytest в процессе) — гард **no-op**, запись как прежде. Прод-блок + **без kill-switch** (выключателя-чёрного-хода нет); второй слой — autouse-floor + `tests/conftest.py::_plane_sandbox_only` (по образцу `_no_telegram`). Детали — `CLAUDE.md` + «Sandbox-only fail-closed изоляция записи в Plane (ORCH-117)», adr-0046. **Страховки:** - Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op. diff --git a/docs/work-items/ORCH-117/00-business-request.md b/docs/work-items/ORCH-117/00-business-request.md new file mode 100644 index 0000000..f32aea5 --- /dev/null +++ b/docs/work-items/ORCH-117/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: test/staging Plane writes must be sandbox-only and never mutate prod + +Work Item ID: ORCH-117 + +## Description + +TBD diff --git a/docs/work-items/ORCH-117/01-brd.md b/docs/work-items/ORCH-117/01-brd.md new file mode 100644 index 0000000..0c136c8 --- /dev/null +++ b/docs/work-items/ORCH-117/01-brd.md @@ -0,0 +1,213 @@ +--- +work_item: ORCH-117 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +escalate: full-cycle +--- + +# 01 — BRD / Bug-report: ORCH-117 — test/staging Plane writes must be sandbox-only and never mutate prod + +Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle** + +> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Задача помечена `Bug`, но по сути это +> **архитектурный + safety-critical (self-hosting)** дефект изоляции окружений: нужно решение о +> том, **где** ставить fail-closed-чокпоинт записи в Plane, **как** детектировать тест-процесс +> (pytest/worktree) в отличие от staging-runtime, и **как** устроен явный аудируемый opt-in для +> sandbox-интеграции. Это требует ADR (политика изоляции + точка перехвата). Поэтому выпускается +> **полный** analysis-пакет, а не облегчённый bug-пакет. Оператор снимает багфикс-трек: +> `POST /bug-fast-track/escalate?work_item=ORCH-117` → задача пойдёт через стадию `architecture` +> (architect выпустит ADR для политики изоляции/чокпоинта). + +--- + +## 1. Бизнес-контекст и проблема + +### Симптом (наблюдаемое — установленный факт из инцидента ORCH-114) +Во время тестирования ORCH-114 **тестовый/worktree-путь выполнил РЕАЛЬНУЮ запись в Plane против +ПРОДАКШН-проекта ORCH**. В логах Plane зафиксировано: +``` +PATCH /issues/dd57ad23... state=3738cd3c... # 3738cd3c == Done ++ comment: "Stage: deploy → done" (branch feature/orch114) +``` +То есть `notify_stage_change("ORCH-114", "deploy", "done")`, запущенный из тестового процесса, +смутировал боевую задачу в Plane — поставил **Done** и оставил комментарий. Это «ложный Done»: +прод-доска показала задачу завершённой по действию тестов, а не конвейера. + +### Причина симптома (установленный факт, верифицировано в коде) +Тест/staging-процессы **имеют доступ к живому Plane-токену и workspace** и **не принуждаются** +писать только в ORCH Sandbox: + +1. **Токен берётся из боевого окружения контейнера.** В `src/plane_sync.py:17` + `PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` фиксируется **на импорте модуля**; + `settings.plane_api_token` читается из env контейнера, где боевой токен **уже установлен**. +2. **Защита в тестах не работает.** Тестовые модули делают + `os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")` (например + `tests/test_deploy_terminal_sync.py:19`, `tests/test_orch026_serialize_integration.py:16` и ~20 + др.). `os.environ.setdefault` **НЕ перекрывает** уже установленную в контейнере переменную → + в проде это **no-op** → тесты наследуют **реальный** токен. Вдобавок, даже если бы env + переписывался ПОСЛЕ импорта `plane_sync`, `PLANE_HEADERS` уже захвачен литералом на импорте и + не пересчитывается (см. п.1) — подмена токена постфактум бесполезна. +3. **Целевой проект по умолчанию — боевой.** `src/plane_sync.py:57` + `PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"` — дефолт + указывает на **боевой ORCH-проект**; ничто не принуждает тест-процесс адресовать только + sandbox `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (identifier `SANDBOX`). +4. **Нет fail-closed-гарда на путях записи.** Все мутирующие вызовы Plane проходят через **три** + примитива в `src/plane_sync.py` — `update_issue_state` (`httpx.patch`, стр. 861), + `add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) — + и **ни один** не проверяет, выполняется ли он в тест-процессе и легитимен ли целевой проект. + +### Прецедент в кодовой базе (почему фикс уместен и как его форму подсказать) +`tests/conftest.py` уже содержит **ровно тот же класс защиты для Telegram**: autouse-фикстура +`_no_telegram` глушит `send_telegram`, потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения +Славе» (дословно из докстринга conftest). Аналогичная autouse-страховка для **Plane-записи** +**отсутствует** — это пробел того же рода, который и реализовался инцидентом ORCH-114. Sandbox как +понятие уже существует: `scripts/staging_check.py:283` фиксирует +`SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`, а проверка B6 утверждает инвариант +«sandbox present ∧ prod-ET absent ∧ prod-ORCH absent» — но **только как read-only верификация +доступа**, а не как **гард записи**. + +### Локализация (куда смотреть архитектору/разработчику) +- **Чокпоинт записи** — три примитива в `src/plane_sync.py` (`update_issue_state`, `add_comment`, + `_set_issue_state_direct`); все `set_issue_*`/`notify_*` сводятся к ним. Гард логично ставить + максимально близко к фактическому `httpx.patch/post` (низкий чокпоинт ловит любой путь, включая + будущие). +- **Захват токена на импорте** — `PLANE_HEADERS`/`PROJECT_ID` модульного уровня (`plane_sync.py:17,57`): + подмена env после импорта не лечит; гард обязан перехватывать **на момент вызова**. +- **Дефолт тестового окружения** — `tests/conftest.py` (autouse, fail-closed по образцу `_no_telegram`). +- **Конфиг opt-in** — `src/config.py` (новые ключи интеграционного включения + sandbox-allowlist). +- **Детект тест-процесса** — в `src/` сейчас **нет** механизма (`PYTEST_CURRENT_TEST`/`sys.modules` + не используются для этого); его предстоит ввести и/или опереть на явный конфиг-флаг. + +**Вывод:** устойчивость должна быть на стороне системы — запись в Plane из тест/worktree-процесса в +**боевой** проект должна быть **физически невозможна** (fail-closed), независимо от того, какой +токен оказался в окружении; sandbox-запись разрешается только при **явном аудируемом opt-in** и +**только** в проект SANDBOX. + +## 2. Объём (scope) + +### В объёме +- **Жёсткая fail-closed изоляция записи в Plane:** прогон unit/test/full-regression (pytest, в т.ч. + из worktree) **не может** мутировать боевые Plane-проекты (state-PATCH и/или comment-POST) — + даже при наличии **живого боевого токена** в окружении. +- **Sandbox-only для реальных тестов:** staging / full-real e2e-тесты, которым нужна настоящая + запись в Plane, адресуют **только** проект ORCH Sandbox (`8c5a3025-…`); любой другой целевой + проект (особенно боевой `7a79f0a9-…`) — запрещён. +- **Явный аудируемый opt-in:** запись в Plane из тест-процесса возможна **исключительно** при + одновременном выполнении: (а) включён выделенный интеграционный флаг, (б) целевой проект ∈ + sandbox-allowlist. Отсутствие любого условия → запись блокируется. +- **Дефолт тестов fail-closed:** autouse-страховка в `tests/conftest.py` (по образцу `_no_telegram`) + блокирует Plane-запись по умолчанию во **всех** тестах. +- **Наблюдаемость/аудит:** каждая заблокированная запись логируется структурно (WARNING/ERROR с + целевым project_id, work_item, операцией); каждая разрешённая sandbox-запись — audit-строкой. +- **Док/конфиг:** обновить `.env.example`, `CLAUDE.md`, `docs/architecture/README.md`, + `docs/operations/INFRA.md` (и, при необходимости, `docs/deployment/*` про тестовую изоляцию). +- **Обязательный регресс-тест:** воспроизводит инцидент ORCH-114 — красный до фикса, зелёный после. + +### Вне объёма +- ❌ Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД + (это bugfix-изоляция, а **не** Quality Gate и **не** стадия). +- ❌ Изменение поведения **боевого рантайма** оркестратора (не-pytest процесс): прод обязан писать + в Plane как прежде, гард для него — no-op. +- ❌ Изменение поведения **staging-рантайма** (8501): staging — реальный процесс оркестратора, + работающий **только** с sandbox-проектом по конфигу; он должен по-прежнему писать в SANDBOX. +- ❌ Запрет/контроль ручных операций оператора (вне технической власти системы). +- ❌ Выбор конкретного механизма детекта тест-процесса и точки перехвата (гард в `plane_sync` vs + обёртка `httpx` vs autouse-фикстура vs комбинация) — **зона архитектора** (ADR). +- ❌ Массовая чистка/нормализация существующих `os.environ.setdefault(...)` строк в тестах сверх + необходимого (можно оставить как есть — гард не должен от них зависеть; см. NFR-4). + +## 3. Заинтересованные стороны +- **Заказчик/оператор (Слава)** — страдает от «ложных Done» и шумных комментариев в боевой Plane, + порождённых тестами; принимает результат. +- **Self-hosting конвейер orchestrator** — прямой потребитель: целостность боевой Plane-доски как + источника индикации (слой B, ORCH-066) не должна искажаться тест-прогонами. +- **Все проекты на общем инстансе (enduro-trails)** — косвенно: тестовая запись не должна задевать + чужие боевые проекты в общем workspace. +- **Разработчики/CI** — потребители sandbox-e2e: должны сохранить возможность реальной проверки + против SANDBOX. + +## 4. Бизнес-требования (BR) +- **BR-1** — Прогон pytest (unit/integration/full-regression), в том числе из worktree, + **НЕ должен** выполнять мутирующую запись в Plane (state-PATCH и/или comment-POST) против + **боевых** проектов — **даже при наличии живого боевого токена** в окружении. Это **fail-closed** + свойство: запись в боевой проект из тест-процесса **невозможна**. +- **BR-2** — Реальная запись в Plane из тест/staging-контекста разрешена **только** в проект + **ORCH Sandbox** (`8c5a3025-4f9d-4190-b79f-fa06276bb27e`); любой иной целевой проект (в т.ч. + боевой ORCH `7a79f0a9-…` и боевой enduro-проект) — запрещён. +- **BR-3** — Реальная sandbox-запись из тест-процесса включается **только** явным аудируемым opt-in: + одновременно (а) выделенный интеграционный флаг включён **и** (б) целевой проект ∈ sandbox-allowlist. + Отсутствие любого условия → запись блокируется (default-deny). +- **BR-4** — Дефолтная тестовая поза — **fail-closed**: при обычном `pytest tests/` (без явного + opt-in) **ни один** тест не может писать в Plane (autouse-страховка в `conftest.py`, по образцу + существующего `_no_telegram`). +- **BR-5** — **Sandbox e2e сохраняется:** с включённым opt-in и целевым проектом SANDBOX реальная + запись в Plane успешно проходит (регрессии sandbox-сценария нет). +- **BR-6** — **Наблюдаемость/аудит:** каждая заблокированная попытка записи логируется структурно + (целевой project_id, work_item, операция, причина блокировки); каждая разрешённая sandbox-запись — + audit-строкой. Инцидент класса ORCH-114 должен быть **видимым**, а не молчаливым. +- **BR-7** — **Документация и конфиг обновлены** в том же PR (golden source наравне с кодом): + `.env.example`, `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`. + +## 5. Нефункциональные требования (NFR) +- **NFR-1 (fail-closed / default-deny)** — при любой неопределённости (не удаётся достоверно + определить целевой проект / окружение / тест-контекст) запись в тест-контексте **запрещается**. + «Не знаю» ⇒ «не пишу». +- **NFR-2 (нулевая регрессия боевого рантайма)** — реальный прод-процесс оркестратора (не pytest) + пишет в Plane **байт-в-байт** как до ORCH-117; гард для него — no-op. `STAGE_TRANSITIONS` / + `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**. +- **NFR-3 (staging-рантайм не сломан)** — staging-инстанс (8501) — **реальный** процесс + оркестратора (не pytest), сконфигурированный на sandbox-проект; он должен **по-прежнему** писать + в SANDBOX. Детект обязан отличать **тест-процесс (pytest/worktree)** от **staging-runtime**. +- **NFR-4 (устойчивость к захвату токена на импорте)** — фикс **не должен** полагаться на подмену + `settings.plane_api_token`/env постфактум (бесполезно из-за модульного захвата `PLANE_HEADERS`/ + `PROJECT_ID`, `plane_sync.py:17,57`) и **не должен** зависеть от неработающего + `os.environ.setdefault(...)` в тестах. Перехват — **на момент вызова** примитива записи. +- **NFR-5 (надёжность / self-hosting safety)** — гард изолирован и **never-raise в боевом пути** + (по образцу leaf'ов `serial_gate`/`cancel`/`deploy_status_guard`): сбой/недоступность логики + гарда не роняет боевой конвейер и не блокирует легитимную боевую запись. В **тест-процессе** + срабатывание гарда должно быть **громким** (блок + аудит, при необходимости — жёсткий fail), + чтобы дефект всплыл, а не замаскировался. +- **NFR-6 (обратимость / kill-switch)** — поведение под флагом по конвенции проекта, **но** + дефолт = **безопасный** (fail-closed в тестах). Kill-switch **не должен** позволять случайно + переоткрыть запись в боевой проект из тестов без явного аудируемого opt-in (BR-3); т.е. + «выключить защиту полностью» не равно «разрешить запись в прод из pytest». +- **NFR-7 (область / композиция)** — изменение скоупится на изоляцию тест/staging-записи; не + ухудшает поведение для прочих репо/боевого рантайма; совместимо с ORCH-066 (статусная модель), + ORCH-094 (deploy-status guard), ORCH-061 (sandbox-infra tolerance staging_check). + +## 6. Допущения и ограничения +- **Все** мутирующие записи в Plane проходят через 3 примитива `src/plane_sync.py` + (`update_issue_state`, `add_comment`, `_set_issue_state_direct`) — это единый узкий чокпоинт + (верифицировано: все `set_issue_*`/`notify_*` сводятся к ним). +- Боевой проект ORCH = `7a79f0a9-5278-49cd-9007-9a338f238f9c` (дефолт `PROJECT_ID`); sandbox = + `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (`SANDBOX`, уже зафиксирован в `scripts/staging_check.py:283`). +- `PLANE_HEADERS`/`PROJECT_ID` захватываются на импорте модуля — гард обязан читать актуальное + состояние/контекст **в момент вызова**, не на импорте. +- Тест-процесс достоверно отличим (например по `PYTEST_CURRENT_TEST` в env, по наличию `pytest` + в `sys.modules`, и/или по явному конфиг-флагу тест-режима) — **выбор признака — вопрос ADR**; + признак должен быть надёжным и не давать ложноположительных срабатываний в боевом/staging + рантайме (NFR-2/NFR-3). +- Конкретный механизм (гард-leaf в `plane_sync` / обёртка над `httpx` / autouse-фикстура / + их комбинация) и протокол opt-in — **открытый вопрос архитектуры**, решается в `06-adr/`. + +## 7. Критерии успеха +Прогон pytest с **живым боевым токеном** в окружении **физически не может** смутировать боевой +ORCH-проект (0 PATCH/POST в боевой проект); sandbox-e2e против SANDBOX по-прежнему работает при +явном opt-in; боевой и staging рантаймы — без регресса; каждая блокировка/разрешение записи — +наблюдаема (аудит-лог); док/конфиг обновлены; обязательный регресс-тест **красный до фикса, +зелёный после**. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски +- **Ложноположительный детект тест-процесса** в боевом/staging рантайме → блокировка легитимной + боевой/sandbox записи (молчаливая потеря индикации Plane). Митигирует NFR-2/NFR-3 + аудит (BR-6). +- **Ложноотрицательный детект** (тест-процесс не распознан) → дефект остаётся → нужен надёжный + признак + fail-closed по умолчанию (NFR-1) + дефолтная autouse-страховка (BR-4). +- **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`): неверная точка перехвата (на импорте, а не + на вызове) даст ложное чувство защиты — жёсткое ограничение для архитектора (NFR-4). +- **Kill-switch как чёрный ход:** грубо реализованный «выключатель» может переоткрыть запись в прод + из тестов — запрещено (NFR-6). +- Кросс-каттинг с ORCH-066 (Plane-индикация), ORCH-094 (deploy-status guard), ORCH-061 + (staging sandbox-infra). Детали/митигации — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-117/02-trz.md b/docs/work-items/ORCH-117/02-trz.md new file mode 100644 index 0000000..d87dc65 --- /dev/null +++ b/docs/work-items/ORCH-117/02-trz.md @@ -0,0 +1,132 @@ +--- +work_item: ORCH-117 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +escalate: full-cycle +--- + +# 02 — ТЗ (TRZ): ORCH-117 — sandbox-only fail-closed изоляция записи в Plane + +Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/решения (точка перехвата, признак тест-процесса, протокол opt-in) — +> задача архитектора (`06-adr/`). Ниже — требования и ограничения, привязанные к реальным модулям. + +## 1. Сводка изменения +Ввести **fail-closed гард записи в Plane**: любая мутирующая запись (state-PATCH / comment-POST), +исходящая из **тест-процесса** (pytest/worktree), блокируется по умолчанию и допускается **только** +при явном аудируемом opt-in **и** целевом проекте из **sandbox-allowlist**. Боевой +рантайм-процесс оркестратора и staging-рантайм (8501) не затронуты (гард для них — no-op). Перехват +выполняется **на момент вызова** примитивов записи `src/plane_sync.py` (а не на импорте, где токен +уже захвачен). Дефолтная тестовая поза — блокировка, через autouse-страховку в `tests/conftest.py` +(по образцу `_no_telegram`). Изменение — bugfix-изоляция: **не** Quality Gate, **не** стадия; +`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не трогаются. + +## 2. Задействованные модули / пути +| Путь | Действие | Зачем | +|------|----------|-------| +| `src/plane_sync.py` | изменить | Врезать гард в 3 примитива записи: `update_issue_state` (`httpx.patch`, стр. 861), `add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047). Учесть захват `PLANE_HEADERS`/`PROJECT_ID` на импорте (стр. 17/57). | +| `src/plane_write_guard.py` *(кандидат, имя — на усмотрение архитектора)* | создать *(вероятно)* | Чистый leaf-гард (never-raise в боевом пути, по образцу `serial_gate`/`cancel`/`deploy_status_guard`): `decide(project_id, op, work_item) -> ALLOW \| BLOCK` + детект тест-процесса + sandbox-allowlist. Альтернатива/комбинация — обёртка над `httpx`/autouse-фикстура (решает ADR). | +| `src/config.py` | изменить | Новые ключи: интеграционный opt-in флаг записи из тестов, sandbox-allowlist проектов, (опц.) kill-switch гарда. Дефолты = безопасные (fail-closed в тестах). | +| `tests/conftest.py` | изменить | Новая autouse-фикстура fail-closed (блок Plane-записи во всех тестах по умолчанию), по образцу `_no_telegram`. Тесты sandbox-e2e переопределяют её своим opt-in (как `test_*` переопределяют `_disable_*`). | +| `.env.example` | изменить | Канон новых `ORCH_*` ключей (opt-in/allowlist/kill-switch) с безопасными дефолтами. | +| `scripts/staging_check.py` | проверить/при необходимости адаптировать | Block C (E2E в SANDBOX) должен остаться рабочим: реальная запись в sandbox под opt-in. `SANDBOX_PROJECT_ID` (стр. 283) — источник идентификатора sandbox. | +| `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md` | изменить | Документировать инвариант изоляции тест/staging-записи (golden source наравне с кодом). | +| `tests/test_orch117_plane_write_isolation.py` *(имя — кандидат)* | создать | Покрытие FR-1…FR-6, включая обязательный регресс ORCH-114 (TC-01). | + +> ⚠️ Список модулей **не** предписывает архитектуру. Точку перехвата (низкий чокпоинт в `plane_sync` +> у `httpx.patch/post` vs обёртка транспорта vs autouse-фикстура) и признак тест-процесса выбирает +> архитектор в ADR. ТЗ фиксирует **требования к поведению**, а не способ реализации. + +## 3. Функциональные требования + +### FR-1 — Fail-closed блок записи в боевой проект из тест-процесса (BR-1, BR-2) +В **тест-процессе** (pytest/worktree) любой вызов `update_issue_state` / `add_comment` / +`_set_issue_state_direct` с целевым `project_id` **вне** sandbox-allowlist (в частности боевой ORCH +`7a79f0a9-5278-49cd-9007-9a338f238f9c` и любой боевой enduro-проект) **НЕ должен** выполнять +`httpx.patch`/`httpx.post` — запись блокируется. Свойство **fail-closed**: при невозможности +достоверно определить целевой проект → блокировать (NFR-1). Гард читает контекст **в момент вызова** +(NFR-4), не полагается на токен/`os.environ.setdefault`. + +### FR-2 — Разрешение только в sandbox при явном аудируемом opt-in (BR-2, BR-3, BR-5) +Запись из тест-процесса допускается ⇔ **одновременно**: (а) включён выделенный интеграционный +opt-in-флаг **и** (б) целевой `project_id` ∈ sandbox-allowlist (по умолчанию — единственный +`8c5a3025-4f9d-4190-b79f-fa06276bb27e`). При выполнении обоих условий примитив выполняет реальный +`httpx`-вызов в SANDBOX. Отсутствие любого условия → блок (default-deny). Запись в боевой проект +запрещена **даже при включённом opt-in** (allowlist sandbox-only). + +### FR-3 — Дефолтная тестовая поза fail-closed (BR-4) +При обычном `pytest tests/` (без явного opt-in) autouse-страховка `conftest.py` гарантирует, что +**ни один** тест не пишет в Plane (все 3 примитива заблокированы/застаблены). Тесты sandbox-e2e, +которым нужна реальная запись, **явно** включают opt-in в собственной фикстуре/монкипатче (поверх +autouse), ограничивая реальную запись своим scope — паттерн уже применён для +`_disable_merge_verify`/`_disable_transition_lease`/`_no_telegram` в `conftest.py`. + +### FR-4 — Детект тест-процесса vs боевой/staging рантайм (NFR-2, NFR-3) +Гард активен **только** в тест-процессе. Признак тест-процесса (например `PYTEST_CURRENT_TEST` +в env / `pytest` в `sys.modules` / явный конфиг-флаг тест-режима — выбор за ADR) обязан: +- **не** срабатывать в боевом рантайм-процессе оркестратора → боевая запись в Plane = байт-в-байт + как прежде (no-op гарда); +- **не** срабатывать в staging-рантайме (8501) → staging пишет в SANDBOX как прежде (staging — + реальный процесс, не pytest). + +### FR-5 — Аудит/наблюдаемость (BR-6) +- Каждая **заблокированная** запись → структурный лог уровня WARNING/ERROR с полями: целевой + `project_id`, `work_item`, операция (`state`/`comment`), причина (`prod-project-in-test` / + `opt-in-disabled` / `ambiguous-target`). Сообщение должно делать инцидент класса ORCH-114 + **очевидным**. +- Каждая **разрешённая** sandbox-запись из тест-процесса → audit-строка (INFO) с `project_id` и + операцией. +- (Опц., на усмотрение архитектора) read-only-видимость состояния гарда (флаг/allowlist) — без + обязательного нового эндпоинта. + +### FR-6 — Поведение блокировки (NFR-5) +- В **боевом пути** гард **never-raise**: его внутренний сбой/недоступность не роняет конвейер и + не блокирует легитимную боевую запись (для боевого процесса гард в принципе no-op — FR-4). +- В **тест-процессе** срабатывание гарда — **громкое**: запись подавляется и аудируется; допустимо + жёсткое исключение/ассерт-фрэндли поведение, чтобы регресс-тест (TC-01) был детерминированно + красным до фикса и зелёным после. Конкретная семантика (no-op-стаб vs raise) — решение ADR, но + **наблюдаемый контракт**: «0 реальных PATCH/POST в боевой проект из pytest». + +### FR-7 — Kill-switch без чёрного хода (NFR-6) +Если вводится kill-switch гарда — он **не должен** при выключении переоткрывать запись в **боевой** +проект из тест-процесса. Допустимое поведение «выключено» = деградация к прежнему (до-ORCH-117), но +без молчаливого разрешения прод-записи из pytest сверх того, что было; запись в SANDBOX из тестов +управляется **только** opt-in-флагом + allowlist (FR-2), а не общим kill-switch. + +## 4. Изменения API +**Нет** обязательных. Никаких новых публичных эндпоинтов изоляция не требует. (Опционально архитектор +может добавить read-only-видимость состояния гарда, например блок в `GET /queue` — не обязательно.) + +## 5. Изменения схемы БД +**Нет.** Изоляция — рантайм-гард по конфигу/окружению; персистентного состояния не требует. Схема БД +не трогается (NFR-2). + +## 6. Требования к новым/изменённым QG checks +**Нет.** Это **не** Quality Gate и **не** под-гейт. `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` / +machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/ +`coverage_status:`) — **байт-в-байт не тронуты** (инвариант ORCH-019 NFR-1: срезается/меняется только +не-гейтовое поведение; здесь — изоляция записи). Гард — свойство клиента Plane, не гейт конвейера. + +## 7. Совместимость / регресс +- **Боевой рантайм:** гард — no-op (FR-4) → запись в Plane байт-в-байт как до ORCH-117 (NFR-2). +- **Staging-рантайм (8501):** реальный процесс, sandbox-проект по конфигу → пишет в SANDBOX как + прежде (NFR-3). `scripts/staging_check.py` Block C (E2E) должен остаться зелёным. +- **Существующий тест-сьют:** autouse fail-closed-фикстура не должна ломать существующие тесты — + большинство тестов **мокируют** `plane_*`/`add_comment` (например `tests/test_auto_labels_integration.py:58` + `monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())`), поэтому реальная запись и + так не происходит; гард лишь делает это **гарантией по умолчанию**. Прежние неработающие + `os.environ.setdefault("ORCH_PLANE_API_TOKEN","test-token")` строки можно не трогать — гард не + зависит от них (NFR-4). +- **Sandbox-e2e:** под явным opt-in + allowlist реальная запись в SANDBOX сохраняется (BR-5). +- **Kill-switch:** при выключении гарда (если введён) — деградация к прежнему поведению, **без** + переоткрытия прод-записи из тестов (FR-7/NFR-6). +- **Обратимость:** дефолты безопасные (fail-closed в тестах); включение реальной записи — + только явным opt-in. +- **Артефакты pipeline:** создаёт/обновляет `docs/work-items/ORCH-117/06-adr/ADR-001-*.md` + (architect, после эскалации), `10-tech-risks.md`; в этом PR — обновление `.env.example`, + `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`. diff --git a/docs/work-items/ORCH-117/03-acceptance-criteria.md b/docs/work-items/ORCH-117/03-acceptance-criteria.md new file mode 100644 index 0000000..3a510ce --- /dev/null +++ b/docs/work-items/ORCH-117/03-acceptance-criteria.md @@ -0,0 +1,145 @@ +--- +work_item: ORCH-117 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +escalate: full-cycle +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-117 — sandbox-only fail-closed изоляция записи в Plane + +Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам +репозитория. + +--- + +## AC-1 — Регресс ORCH-114: живой прод-токен + pytest не мутируют боевой проект (ОБЯЗАТЕЛЬНЫЙ) + +**Условие:** В тест-процессе с **живым боевым** `ORCH_PLANE_API_TOKEN` в окружении вызывается +`notify_stage_change("ORCH-114", "deploy", "done")` (и/или прямые `update_issue_state`/`add_comment`/ +`_set_issue_state_direct`) с целевым боевым проектом `7a79f0a9-5278-49cd-9007-9a338f238f9c`. +- **PASS:** **ноль** реальных `httpx.patch`/`httpx.post` уходит в боевой проект (мок `httpx` не + вызван для prod-URL **или** гард блокирует до сетевого вызова). Существует тест, который **красный + до фикса** (воспроизводит запись инцидента) и **зелёный после**. +- **FAIL:** хоть один PATCH/POST достигает боевого проекта из тест-процесса; либо регресс-тест + отсутствует; либо он зелёный и до фикса (значит ничего не проверяет). + +--- + +## AC-2 — Sandbox-e2e сохранён: запись в SANDBOX под opt-in проходит + +**Условие:** В тест-процессе включён явный интеграционный opt-in **и** целевой проект — SANDBOX +`8c5a3025-4f9d-4190-b79f-fa06276bb27e`. +- **PASS:** примитивы записи выполняют реальный `httpx`-вызов в SANDBOX (в тесте — через мок, + подтверждающий, что вызов разрешён и адресован sandbox-URL); `scripts/staging_check.py` Block C + (E2E в SANDBOX) остаётся работоспособным. +- **FAIL:** запись в SANDBOX заблокирована при корректном opt-in; либо sandbox-e2e сломан. + +--- + +## AC-3 — Sandbox-only даже с opt-in: боевой проект запрещён всегда + +**Условие:** В тест-процессе включён opt-in, но целевой проект — боевой (`7a79f0a9-…` ORCH или +боевой enduro-проект). +- **PASS:** запись блокируется (allowlist sandbox-only) независимо от opt-in; аудит-лог фиксирует + причину `prod-project-in-test`. +- **FAIL:** включённый opt-in разрешает запись в любой проект, включая боевой. + +--- + +## AC-4 — Default-deny: без opt-in запись из тестов заблокирована (fail-closed) + +**Условие:** Обычный `pytest tests/` без явного opt-in; целевой проект — любой (sandbox или боевой). +- **PASS:** все 3 примитива (`update_issue_state`, `add_comment`, `_set_issue_state_direct`) не + делают реальной записи; autouse-фикстура `conftest.py` обеспечивает это по умолчанию во всех + тестах. Неопределённый/неразрешимый целевой проект → блок (NFR-1). +- **FAIL:** без opt-in возможна реальная запись в Plane; либо autouse-страховка отсутствует. + +--- + +## AC-5 — Нулевая регрессия боевого рантайма + +**Условие:** Процесс — **не** pytest (боевой рантайм оркестратора). +- **PASS:** гард — no-op; запись в Plane выполняется как до ORCH-117 (тот же URL/headers/payload). + `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict ключи/схема БД — **байт-в-байт не + изменены** (проверяемо diff'ом / структурными тестами). +- **FAIL:** боевая запись подавлена/изменена; либо изменены гейты/схема БД. + +--- + +## AC-6 — Staging-рантайм пишет в SANDBOX + +**Условие:** Процесс — staging-рантайм (8501), **не** pytest; сконфигурирован на sandbox-проект. +- **PASS:** детект тест-процесса **не** срабатывает для staging → запись в SANDBOX проходит как + прежде; staging не приравнивается к pytest. +- **FAIL:** staging-запись в SANDBOX заблокирована (ложноположительный детект тест-процесса). + +--- + +## AC-7 — Устойчивость к захвату токена на импорте + +**Условие:** `PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте `plane_sync` (стр. 17/57); тест +выполняет запись, не подменяя токен/env постфактум. +- **PASS:** гард срабатывает **на момент вызова** примитива и блокирует прод-запись независимо от + того, какой токен в `PLANE_HEADERS`; защита **не** опирается на `os.environ.setdefault(...)`/ + подмену `settings.plane_api_token`. +- **FAIL:** защита зависит от подмены токена/env и потому не срабатывает в проде (как было до фикса). + +--- + +## AC-8 — Аудит/наблюдаемость блокировок и разрешений + +**Условие:** Происходит блокировка записи (или разрешённая sandbox-запись). +- **PASS:** на блокировку эмитится структурный WARNING/ERROR с `project_id`, `work_item`, операцией и + причиной; на разрешённую sandbox-запись — audit-INFO. Сообщения делают инцидент видимым. +- **FAIL:** блокировка/разрешение происходят молча (нет логов с требуемыми полями). + +--- + +## AC-9 — Kill-switch без чёрного хода + +**Условие:** Если введён kill-switch гарда — он выключен. +- **PASS:** выключение деградирует к прежнему (до-ORCH-117) поведению, но **не** разрешает молча + запись в **боевой** проект из pytest сверх того, что было; реальная sandbox-запись из тестов + управляется только opt-in + allowlist (не общим kill-switch). +- **FAIL:** общий kill-switch служит чёрным ходом, переоткрывающим прод-запись из тест-процесса. + +--- + +## AC-10 — Документация и конфиг обновлены (golden source) + +**Условие:** PR закрывает ORCH-117. +- **PASS:** обновлены `.env.example` (новые `ORCH_*` ключи с безопасными дефолтами), `CLAUDE.md`, + `docs/architecture/README.md`, `docs/operations/INFRA.md` — описан инвариант изоляции тест/staging + записи в Plane. ADR выпущен (`06-adr/ADR-001-*.md`). +- **FAIL:** код есть, документация/конфиг не обновлены (по правилу reviewer'а ORCH — finding ≥P1). + +--- + +## AC-11 — Полный регресс зелёный + +**Условие:** `pytest tests/ -q` после фикса. +- **PASS:** весь сьют зелёный (автоматическая autouse-страховка не ломает существующие тесты). +- **FAIL:** появились падения/флапы из-за внедрённого гарда/фикстуры. + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 / FR-6 (обязательный регресс ORCH-114) | +| AC-2 | BR-5 / FR-2 | +| AC-3 | BR-2 / FR-2 | +| AC-4 | BR-3 / BR-4 / FR-2 / FR-3 / NFR-1 | +| AC-5 | NFR-2 / FR-4 | +| AC-6 | NFR-3 / FR-4 | +| AC-7 | NFR-4 / FR-1 | +| AC-8 | BR-6 / FR-5 | +| AC-9 | NFR-6 / FR-7 | +| AC-10 | BR-7 | +| AC-11 | NFR-2 (регресс-нейтральность) | diff --git a/docs/work-items/ORCH-117/04-test-plan.yaml b/docs/work-items/ORCH-117/04-test-plan.yaml new file mode 100644 index 0000000..81c5601 --- /dev/null +++ b/docs/work-items/ORCH-117/04-test-plan.yaml @@ -0,0 +1,113 @@ +work_item: ORCH-117 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +title: "Sandbox-only fail-closed изоляция записи в Plane (регресс ORCH-114)" +framework: pytest +scope: > + Покрывает fail-closed гард записи в Plane: блок прод-записи из тест-процесса даже при живом + боевом токене (обязательный регресс ORCH-114), sandbox-only-разрешение под явным opt-in, + default-deny по умолчанию, отличие тест-процесса от боевого/staging рантайма, перехват на + момент вызова (устойчивость к захвату токена на импорте), аудит. ВНЕ покрытия: реальные сетевые + вызовы к боевому Plane (запрещены самим фиксом) — всё через мок httpx; выбор механизма детекта — + зона ADR (тесты проверяют поведение, не реализацию). +notes: > + TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс инцидента ORCH-114: красный до фикса, зелёный после. Все три + примитива записи (update_issue_state / add_comment / _set_issue_state_direct) проверяются на + блок/разрешение через мок httpx.patch/httpx.post (никаких реальных сетевых вызовов). Полный + регресс tests/ должен оставаться зелёным (autouse fail-closed-фикстура не ломает существующие + тесты, большинство из которых уже мокируют plane_*/add_comment). Боевой ID проекта в тестах — + 7a79f0a9-5278-49cd-9007-9a338f238f9c; sandbox — 8c5a3025-4f9d-4190-b79f-fa06276bb27e. + +tests: + - id: TC-01 + type: integration + description: "РЕГРЕСС ORCH-114: pytest-env + живой прод-токен → notify_stage_change('ORCH-114','deploy','done') на боевой проект НЕ делает ни одного httpx.patch/post (мок httpx не вызван для prod-URL / гард блокирует). Красный до фикса, зелёный после." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-02 + type: unit + description: "update_issue_state в тест-процессе с целевым боевым проектом 7a79f0a9-… → блок (httpx.patch не вызван); аудит-причина prod-project-in-test." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-03 + type: unit + description: "add_comment в тест-процессе с боевым проектом → блок (httpx.post не вызван)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-04 + type: unit + description: "_set_issue_state_direct в тест-процессе с боевым проектом → блок (httpx.patch не вызван). Покрывает все set_issue_* (Done/In Review/Blocked/…), сводящиеся к этому примитиву." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-05 + type: unit + description: "Default-deny: без явного opt-in запись в тест-процессе блокируется для ЛЮБОГО целевого проекта (в т.ч. sandbox)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-06 + type: unit + description: "Sandbox-разрешение: opt-in включён + целевой проект SANDBOX 8c5a3025-… → реальный httpx-вызов разрешён и адресован sandbox-URL (мок подтверждает вызов)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-07 + type: unit + description: "Sandbox-only даже с opt-in: opt-in включён, но целевой проект боевой → блок (allowlist sandbox-only), независимо от opt-in." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-08 + type: unit + description: "Fail-closed при неопределённости: целевой project_id неразрешим/пуст в тест-процессе → блок (NFR-1 'не знаю ⇒ не пишу')." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-09 + type: unit + description: "Устойчивость к захвату на импорте: PLANE_HEADERS содержит реальный токен, env/settings не подменяются постфактум → гард всё равно блокирует прод-запись на момент вызова (не зависит от os.environ.setdefault / подмены plane_api_token)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-10 + type: unit + description: "Нулевая регрессия боевого рантайма: при имитации НЕ-pytest процесса гард = no-op, httpx.patch/post вызывается с прежним URL/headers/payload (запись в Plane как до ORCH-117)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-11 + type: unit + description: "Staging != pytest: имитация staging-рантайма (sandbox-проект, не тест-процесс) → запись в SANDBOX проходит (детект тест-процесса не срабатывает ложно)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-12 + type: unit + description: "Аудит: на блокировку эмитится структурный WARNING/ERROR с project_id/work_item/операцией/причиной (caplog); на разрешённую sandbox-запись — audit-INFO." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-13 + type: integration + description: "Дефолтная autouse-страховка conftest: репрезентативный advance стадии в обычном тесте не делает реальной записи в боевой Plane (страховка активна по умолчанию для всего сьюта)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-14 + type: unit + description: "Kill-switch без чёрного хода: при выключенном kill-switch гарда запись в БОЕВОЙ проект из pytest всё равно не разрешается молча (реальная sandbox-запись управляется только opt-in+allowlist)." + module: tests/test_orch117_plane_write_isolation.py + expected: PASS + + - id: TC-15 + type: integration + description: "Полный регресс tests/ зелёный — внедрённая autouse fail-closed-фикстура не ломает существующие тесты (smoke: pytest tests/ -q)." + module: tests/ + expected: PASS 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` не ставится; возврат в анализ не нужен. diff --git a/docs/work-items/ORCH-117/12-review.md b/docs/work-items/ORCH-117/12-review.md new file mode 100644 index 0000000..64b2b24 --- /dev/null +++ b/docs/work-items/ORCH-117/12-review.md @@ -0,0 +1,107 @@ +--- +verdict: APPROVED +work_item: ORCH-117 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-117 +version: 1 +--- + +# Review ORCH-117 — sandbox-only fail-closed изоляция записи в Plane + +## Summary +Багфикс-трек (bug → escalate full-cycle) закрывает корневой класс инцидента **ORCH-114**: тест/ +worktree-процесс выполнял РЕАЛЬНУЮ запись (`PATCH …/issues/… state=` + комментарий) против +**боевого** Plane-проекта, наследуя живой боевой токен. Реализован чистый never-raise leaf +`src/plane_write_guard.py` (`decide()` + `audit_*` + `snapshot`), врезанный через тонкий хелпер +`_guard_allows_write` в **3 (все) примитива записи** `plane_sync` (`update_issue_state`/`add_comment`/ +`_set_issue_state_direct`) на момент вызова — сразу после `_resolve_project_id` и до любого сетевого +шага. Второй sandbox-bound слой — autouse-floor `tests/conftest.py::_plane_sandbox_only`. + +Реализация **точно** соответствует ADR-001 (D1–D7) и сквозному adr-0046. Все четыре оси проверки +пройдены, P0/P1-findings нет. + +**Проведённая верификация (фактический прогон):** +- `pytest tests/test_orch117_plane_write_isolation.py` → **16 passed** (TC-01…TC-14). +- **Обязательный регресс ORCH-019 BR-4 подтверждён вручную:** откатил врезку `plane_sync.py` на + версию `origin/main` → **TC-01 КРАСНЫЙ** (`httpx.patch` уходит на боевой проект + `7a79f0a9-…` с `LIVE-PROD-TOKEN` — воспроизводит инцидент); с фиксом — **ЗЕЛЁНЫЙ**. Тест-фиксатор + дефекта содержателен. +- `pytest tests/ -q` → **2068 passed** (AC-11 — нулевая регрессия, включая `test_system_docs.py`). +- `grep httpx.(patch|post|put|delete)` по `plane_sync.py` → ровно 3 write-call-site, **все** + под гардом; необёрнутых примитивов записи нет. +- `git diff --name-only src/` → изменены **только** `config.py`/`plane_sync.py`/`plane_write_guard.py`; + `stages.py`/`qg/`/`db.py`/`stage_engine.py` — **не тронуты**. + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- Нет. + +### P3 — Nice-to-have (не блокирует) +- [ ] Четыре существующих теста (`test_plane_author.py`, `test_plane_status_model.py`, + `test_plane_sync_labels.py`, `test_stage_visibility.py`) обходят гард монкипатчем + `decide → (True, "test-bypass")`. Это легитимно (per-test autouse, авто-revert; `httpx` в этих + тестах замокан → реальной записи быть не может; сам гард покрыт отдельным сьютом; паттерн — + аналог `_disable_merge_verify`). Чуть более хирургичной альтернативой был бы opt-in + добавление + целевого проекта в allowlist, но тесты проверяют маршрутизацию именно в НЕ-sandbox проекты, так + что полный bypass оправдан и явно задокументирован в каждом docstring. Менять не требуется. + +## Ось 1 — Соответствие ТЗ (02-trz / 03-acceptance-criteria) +- **FR-1** (fail-closed блок прод-записи из теста) — ✅ TC-01/02/03/04/09. +- **FR-2** (разрешено только sandbox + opt-in) — ✅ TC-06/07; `decide` шаги 3–5 1:1 с таблицей ADR. +- **FR-3** (дефолтная поза fail-closed через conftest-floor) — ✅ TC-05/13. +- **FR-4** (детект тест-процесса; no-op в боевом/staging) — ✅ TC-10/11; `_in_test_process` = + `"pytest" in sys.modules` / `PYTEST_CURRENT_TEST`, call-time. +- **FR-5** (аудит блок ERROR / allow INFO с полями) — ✅ TC-12. +- **FR-6** (never-raise в боевом пути; громко в тесте) — ✅ live-path возвращает ALLOW ДО try-блока; + in-test исключение → fail-CLOSED `guard-error`. +- **FR-7** (kill-switch без чёрного хода) — ✅ TC-14 (ассерт отсутствия `plane_write_guard_enabled`). +- **AC-1…AC-11** — все выполнены; AC-1 (обязательный регресс) и AC-11 (полный регресс) подтверждены + фактическим прогоном (см. Summary). + +## Ось 2 — Соответствие ADR (06-adr/ADR-001 + adr-0046) +- D1 чокпоинт (после `_resolve_project_id`, до сети, 3 примитива) — ✅ сверено по diff. +- D2 детект тест-процесса — ✅. D3 `decide` default-deny/sandbox-allowlist/opt-in — ✅ 1:1 с таблицей. +- D4 **умышленно без kill-switch прод-блока** — ✅ соблюдено (важный анти-дрейф: добавление общего + выключателя реинтродуцировало бы дефект ORCH-114; reviewer ловил бы как ≥P1 — здесь его нет). +- D5 conftest-floor — ✅. D6 конфиг-ключи (`plane_test_write_enabled=False`, + `plane_test_sandbox_projects=8c5a3025-…`) — ✅. D7 аудит — ✅. +- **Инварианты не сломаны:** `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и имена `check_*`/ + machine-verdict-ключи/схема БД — байт-в-байт (диф трогает только 3 src-файла; гейтовые модули + не изменены). Трассировка (ORCH-078): врезка в 3 примитива не ломает чужих маркированных + инвариантов. + +## Ось 3 — Качество кода +- never-raise дисциплина корректна: внешний (боевой) путь физически не может упасть из-за гарда + (return до try); внутренний тест-путь fail-CLOSED. +- Полные docstrings на всех публичных функциях; стабильные reason-слаги вынесены константами и + проверяются тестами. +- **Багфикс-регресс (ORCH-019 BR-4):** TC-01 — фиксатор дефекта, красный до фикса / зелёный после + (проверено вручную). Требование выполнено. +- Покрытие всех 3 точек записи; необёрнутых write-примитивов не осталось. + +## Документация +Обновлена полностью (golden source наравне с кодом), AC-10 выполнен: +- `CLAUDE.md` — новый раздел «Sandbox-only fail-closed изоляция записи в Plane (ORCH-117)». ✅ +- `docs/architecture/README.md` — новый компонент «Plane write guard». ✅ +- `docs/operations/INFRA.md` — таблица env (`ORCH_PLANE_TEST_WRITE_ENABLED`/ + `ORCH_PLANE_TEST_SANDBOX_PROJECTS`) + раздел «что изолировано». ✅ +- `.env.example` — оба ключа с безопасными дефолтами. ✅ +- `CHANGELOG.md` — запись `[Unreleased]`. ✅ +- ADR: `06-adr/ADR-001-sandbox-only-plane-write-guard.md` + сквозной + `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`. ✅ +- **Обзорные доки (ORCH-079) / витрина `docs/overview/` (ORCH-011):** обновлять не требуется — + ORCH-117 это защитный тест-изоляционный гард, а не ранее задокументированное «Известное + ограничение» README и не изменение функциональности конвейера/стадий/гейтов/агентов/интеграций, + показываемой в витрине; `test_system_docs.py` зелёный в полном прогоне. diff --git a/docs/work-items/ORCH-117/13-test-report.md b/docs/work-items/ORCH-117/13-test-report.md new file mode 100644 index 0000000..410fd6a --- /dev/null +++ b/docs/work-items/ORCH-117/13-test-report.md @@ -0,0 +1,69 @@ +--- +result: PASS +work_item: ORCH-117 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-117 +--- + +# Test Report — ORCH-117 — sandbox-only fail-closed изоляция записи в Plane + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-117-bug-test-staging-plane-writes-` +- Ветка: `feature/ORCH-117-bug-test-staging-plane-writes-` +- Дата: 2026-06-15 + +## Smoke API (read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` ✅ +- `GET /status` → 200, ORCH-117 (task 104) на стадии `testing`, `agent_running: null` ✅ +- `GET /queue` → 200; блок `serial_gate` присутствует ✅; блок `auto_labels` присутствует ✅ + (регресс смока ORCH-088 не обнаружен). + +## Результаты — покрытие test-plan (04-test-plan.yaml) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | РЕГРЕСС ORCH-114: pytest-env + живой прод-токен → `notify_stage_change('ORCH-114','deploy','done')` на боевой проект делает 0 httpx.patch/post | `test_tc01_notify_stage_change_prod_makes_zero_writes` | PASS | +| TC-02 | `update_issue_state` в тест-процессе с боевым проектом → блок; аудит `prod-project-in-test` | `test_tc02_update_issue_state_prod_blocked` | PASS | +| TC-03 | `add_comment` в тест-процессе с боевым проектом → блок (httpx.post не вызван) | `test_tc03_add_comment_prod_blocked` | PASS | +| TC-04 | `_set_issue_state_direct` + `set_issue_done` в тест-процессе с боевым проектом → блок | `test_tc04_set_issue_state_direct_prod_blocked`, `test_tc04_set_issue_done_prod_blocked` | PASS | +| TC-05 | Default-deny: без opt-in запись блокируется для ЛЮБОГО проекта (вкл. sandbox) | `test_tc05_default_deny_blocks_sandbox_and_prod` | PASS | +| TC-06 | Sandbox-разрешение: opt-in + SANDBOX `8c5a3025-…` → реальный httpx-вызов разрешён | `test_tc06_sandbox_optin_allows_write` | PASS | +| TC-07 | Sandbox-only даже с opt-in: opt-in включён, боевой проект → блок | `test_tc07_optin_still_blocks_prod` | PASS | +| TC-08 | Fail-closed при неопределённости: пустой/неразрешимый project_id → блок | `test_tc08_ambiguous_target_blocked` | PASS | +| TC-09 | Устойчивость к захвату токена на импорте: гард блокирует на момент вызова | `test_tc09_blocks_regardless_of_captured_token` | PASS | +| TC-10 | Нулевая регрессия боевого рантайма: НЕ-pytest → гард no-op, реальный httpx-вызов | `test_tc10_live_runtime_is_noop` | PASS | +| TC-11 | Staging != pytest: staging-рантайм (sandbox) → запись проходит | `test_tc11_staging_writes_sandbox` | PASS | +| TC-12 | Аудит: блок → структурный WARNING/ERROR с полями; sandbox-allow → audit-INFO | `test_tc12_block_audited_loudly`, `test_tc12_sandbox_allow_audited_info` | PASS | +| TC-13 | Дефолтная autouse-страховка conftest: обычный тест не пишет в боевой Plane | `test_tc13_conftest_floor_default_deny` | PASS | +| TC-14 | Kill-switch без чёрного хода: прод-запись из pytest не разрешается молча | `test_tc14_no_killswitch_backdoor` | PASS | +| TC-15 | Полный регресс `tests/ -q` зелёный (autouse-фикстура не ломает существующие тесты) | `pytest tests/` | PASS | + +Сопоставление с `03-acceptance-criteria.md`: AC-1↔TC-01, AC-2↔TC-06, AC-3↔TC-07, +AC-4↔TC-05/08, AC-5↔TC-10, AC-6↔TC-11, AC-7↔TC-09, AC-8↔TC-12, AC-9↔TC-14, +AC-10 (docs/config — проверено reviewer'ом, APPROVED), AC-11↔TC-15. Все AC покрыты. + +## Вывод pytest + +Целевой файл: +``` +tests/test_orch117_plane_write_isolation.py ... 16 passed, 1 warning in 0.40s +(TC-01…TC-14; TC-04 и TC-12 — по два теста) +``` + +Полный регресс: +``` +2068 passed, 1 warning in 88.02s (0:01:28) +``` +(единственный warning — PydanticDeprecatedSince20 в `src/config.py:8`, не связан с ORCH-117.) + +## Итог +**PASS** — все 15 TC выполнены и сопоставлены с критериями приёмки; целевой сьют (16 тестов) и +полный регресс (2068 passed) зелёные; smoke API (`/health`, `/status`, `/queue` с блоками +`serial_gate`/`auto_labels`) — OK. Задача готова к переходу на `deploy-staging`. diff --git a/docs/work-items/ORCH-117/14-deploy-log.md b/docs/work-items/ORCH-117/14-deploy-log.md new file mode 100644 index 0000000..3983ceb --- /dev/null +++ b/docs/work-items/ORCH-117/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-117 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/src/config.py b/src/config.py index a21b7c8..888ef66 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,25 @@ class Settings(BaseSettings): plane_bot_deployer: str = "" plane_bot_stream: str = "" + # ORCH-117 (ADR-001 D6): sandbox-only fail-closed guard for Plane WRITE + # primitives from a test/worktree process (regression of incident ORCH-114, + # where a pytest run mutated a live prod board issue). The guard (leaf + # src/plane_write_guard.py) is a no-op in the live runtime (no pytest in the + # uvicorn process); in a test process it blocks every Plane write UNLESS both + # the opt-in flag is ON and the target project is in the sandbox allowlist. + # plane_test_write_enabled -> opt-in for REAL Plane writes from a test process + # (env ORCH_PLANE_TEST_WRITE_ENABLED). Default False + # = safe (default-deny). NOT a kill-switch for the + # prod-block: even ON, only sandbox projects are + # writable (allowlist below); a prod write from + # pytest stays physically impossible (NFR-6/FR-7). + # plane_test_sandbox_projects -> CSV allowlist of sandbox project ids the opt-in + # may write to (env ORCH_PLANE_TEST_SANDBOX_PROJECTS). + # Default = the single SANDBOX project. Empty -> no + # project is writable from a test process at all. + plane_test_write_enabled: bool = False + plane_test_sandbox_projects: str = "8c5a3025-4f9d-4190-b79f-fa06276bb27e" + # Gitea gitea_url: str = "http://localhost:3000" gitea_public_url: str = "" # external URL for clickable links in comments; falls back to gitea_url diff --git a/src/plane_sync.py b/src/plane_sync.py index e501bd5..c7c90b6 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -4,6 +4,7 @@ import logging import time import httpx from .config import settings +from . import plane_write_guard logger = logging.getLogger("orchestrator.plane_sync") @@ -843,9 +844,30 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: return None +def _guard_allows_write(work_item_id: str, project_id: str, op: str) -> bool: + """ORCH-117: fail-closed gate in front of every Plane WRITE (state/comment). + + Returns True if the write may proceed. In the live orchestrator/staging runtime + this is always True (the guard is a no-op — no pytest in the process). In a + test/worktree process a non-sandbox / non-opt-in write is BLOCKED here (audited + loudly) and this returns False, so the calling primitive returns BEFORE any + network step (no GET, no PATCH/POST). See src/plane_write_guard.py / ORCH-114. + """ + ok, reason = plane_write_guard.decide(project_id, op, work_item_id) + if not ok: + plane_write_guard.audit_block(project_id, op, work_item_id, reason) + return False + if reason == plane_write_guard.R_SANDBOX_OPT_IN: + plane_write_guard.audit_allow(project_id, op, work_item_id, reason) + return True + + def update_issue_state(work_item_id: str, stage: str, project_id: str = None): """Update Plane issue state based on orchestrator stage.""" project_id = _resolve_project_id(work_item_id, project_id) + # ORCH-117: fail-closed guard — block prod Plane writes from a test process. + if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_STATE): + return # ORCH-10: resolve state UUID for this specific project (not global dict). state_id = stage_to_state(stage, project_id) if not state_id: @@ -874,6 +896,9 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st ``_headers_for``). GET/PATCH calls elsewhere keep using PLANE_HEADERS. """ project_id = _resolve_project_id(work_item_id, project_id) + # ORCH-117: fail-closed guard — block prod Plane comment-writes from a test process. + if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_COMMENT): + return issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment") @@ -1038,6 +1063,9 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None) def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None): """Set issue state directly by state_id.""" project_id = _resolve_project_id(work_item_id, project_id) + # ORCH-117: fail-closed guard — block prod Plane writes from a test process. + if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_STATE): + return issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") diff --git a/src/plane_write_guard.py b/src/plane_write_guard.py new file mode 100644 index 0000000..0ff4201 --- /dev/null +++ b/src/plane_write_guard.py @@ -0,0 +1,193 @@ +"""ORCH-117: fail-closed guard for Plane WRITE primitives from a test/worktree process. + +Leaf module — pure, never-raise in the live path, config-gated. Mirrors the leaf +pattern of ``src/deploy_status_guard.py`` / ``src/serial_gate.py`` / ``src/cancel.py``: +it imports only ``config`` (and stdlib ``os``/``sys``), never ``plane_sync`` / +``stage_engine`` — the three write primitives that need a verdict call +:func:`decide`, the guard does not live there. + +The incident (established fact, ORCH-114). A test/worktree process performed a REAL +write to Plane against the **production** project: ``PATCH …/issues/… state=`` +plus a "Stage: deploy → done" comment, i.e. ``notify_stage_change("ORCH-114", +"deploy","done")`` run from pytest mutated a live board issue ("false Done"). The +root: test/staging processes inherit the live Plane token (``PLANE_HEADERS`` / +``PROJECT_ID`` are captured as literals at module import, so a post-hoc env/token +swap is a no-op, NFR-4), and *nothing* forced them to write only to the sandbox. + +The precedent. ``tests/conftest.py::_no_telegram`` is an autouse fixture muting +``send_telegram`` in ALL tests, exactly because "pytest on prod sent REAL Telegram +messages to Slava". The symmetric protection for Plane WRITES did not exist — this +is that protection. + +The fix (ADR-001 D1/D3): a low choke-point on the entry of the three write +primitives, evaluated **at call time** (not at import). The guard is active **only +in a test process** (``pytest``-in-process detection) — for the live orchestrator +runtime and the staging runtime (both ``uvicorn src.main:app``, no pytest in the +process) it is a strict no-op (byte-for-byte, NFR-2/NFR-3). In a test process a +write is allowed **iff** simultaneously (a) the dedicated opt-in flag is ON **and** +(b) the target project ∈ the sandbox-allowlist; otherwise BLOCK (default-deny). A +non-resolvable target → BLOCK (fail-closed, NFR-1). An allowed sandbox write is +audited at INFO; a block is audited LOUDLY at ERROR so an ORCH-114-class incident is +obvious, not silent (FR-5 / D7). + +Deliberately NO kill-switch for the prod-block (ADR-001 D4 / FR-7 / NFR-6): a guard +that makes a prod write from pytest *physically impossible* must not ship with a +config that re-opens it (that would be the very back-door NFR-6 forbids). The only +reversible regulator is the sandbox-bound opt-in (``plane_test_write_enabled`` + +``plane_test_sandbox_projects``); "disable the guard" ≠ "allow prod from pytest" — +that transition does not exist by design. The independent conftest floor +(``_plane_sandbox_only``, ADR-001 D5) is the second sandbox-bound layer. + +This is bugfix-isolation, NOT a Quality Gate and NOT a stage: ``STAGE_TRANSITIONS`` / +``QG_CHECKS`` / ``check_*`` / machine-verdict keys / the DB schema are byte-for-byte +untouched. +""" +from __future__ import annotations + +import logging +import os +import sys + +from .config import settings + +logger = logging.getLogger("orchestrator.plane_write_guard") + +# Verdicts returned by decide() (the calling primitive executes them). +ALLOW = True +BLOCK = False + +# Reasons (stable slugs — asserted by tests / read in audit lines). +R_LIVE_RUNTIME = "live-runtime" # not a test process -> no-op (prod/staging). +R_AMBIGUOUS = "ambiguous-target" # project_id empty/unresolved -> "don't know => don't write". +R_OPT_IN_DISABLED = "opt-in-disabled" # test process, opt-in OFF -> default-deny. +R_PROD_IN_TEST = "prod-project-in-test" # test process, project NOT in sandbox allowlist. +R_SANDBOX_OPT_IN = "sandbox-opt-in" # test process, opt-in ON + sandbox project -> ALLOW. +R_GUARD_ERROR = "guard-error" # internal error inside the test-path -> fail-closed BLOCK. + +# Operation tokens (one per call site) — used only for the audit line. +OP_STATE = "state" # update_issue_state / _set_issue_state_direct (httpx.patch) +OP_COMMENT = "comment" # add_comment (httpx.post) + + +def _in_test_process() -> bool: + """True iff this Python process is a pytest/worktree test process (ADR-001 D2). + + ``"pytest" in sys.modules`` is true for the whole pytest run (collection + + execution) in THIS process, which is exactly the worktree ``python -m pytest`` + process from the incident. The live orchestrator and the staging runtime start + via ``uvicorn src.main:app`` and never import pytest into their process, so the + detection never fires there (NFR-2/NFR-3, AC-5/AC-6). ``PYTEST_CURRENT_TEST`` is + a secondary confirming signal pytest sets for the duration of a test body. Both + are read at call time (NFR-4). Never raises: on any error we treat the process + as NOT-a-test (-> live ALLOW), so the guard can never accidentally wedge a + legitimate prod write. + """ + try: + if "pytest" in sys.modules: + return True + if os.environ.get("PYTEST_CURRENT_TEST"): + return True + except Exception: # noqa: BLE001 - never-raise; ambiguity -> "not a test" (live ALLOW). + return False + return False + + +def _sandbox_allowlist() -> set[str]: + """Sanitised set of sandbox project ids from ``plane_test_sandbox_projects``. + + Empty/blank CSV -> empty set (then EVERY project blocks in a test process, + fail-closed). Never raises. + """ + try: + raw = (settings.plane_test_sandbox_projects or "").strip() + except Exception: # noqa: BLE001 - never-raise. + return set() + if not raw: + return set() + return {tok.strip() for tok in raw.split(",") if tok.strip()} + + +def decide(project_id: str | None, op: str, work_item_id: str | None = None) -> tuple[bool, str]: + """Decide whether a Plane WRITE primitive may hit the network (ADR-001 D3). + + Returns ``(ok, reason)``. Steps: + + 1. ``not _in_test_process()`` -> ALLOW (``live-runtime``: prod/staging no-op). + 2. ``project_id`` empty/None/unresolved -> BLOCK (``ambiguous-target``, NFR-1). + 3. opt-in flag OFF -> BLOCK (``opt-in-disabled``, default-deny). + 4. ``project_id`` ∉ sandbox allowlist -> BLOCK (``prod-project-in-test``, AC-3). + 5. otherwise -> ALLOW (``sandbox-opt-in``, audit INFO). + + never-raise: the live path returns at step 1 BEFORE the try-block, so a prod + write can never be wedged by a guard bug. Once we know we are in a test process, + any internal error fails CLOSED to BLOCK (``guard-error``) — the defect surfaces + loudly rather than re-opening prod (AC-9 / FR-7). + """ + # Step 1 — outside any test process the guard is a strict no-op. Evaluated FIRST + # and OUTSIDE the try-block so a live prod/staging write is never affected. + if not _in_test_process(): + return ALLOW, R_LIVE_RUNTIME + + # From here on we are in a test process: default-deny, fail-closed on any error. + try: + pid = (project_id or "").strip() + if not pid: + return BLOCK, R_AMBIGUOUS # step 2 + if not bool(getattr(settings, "plane_test_write_enabled", False)): + return BLOCK, R_OPT_IN_DISABLED # step 3 + if pid not in _sandbox_allowlist(): + return BLOCK, R_PROD_IN_TEST # step 4 — sandbox-only, even with opt-in. + return ALLOW, R_SANDBOX_OPT_IN # step 5 + except Exception as e: # noqa: BLE001 - never-raise; in-test -> fail CLOSED. + logger.error( + "plane_write_guard.decide error in test-process -> BLOCK (fail-closed): %s", e + ) + return BLOCK, R_GUARD_ERROR + + +def audit_block(project_id: str | None, op: str, work_item_id: str | None, reason: str) -> None: + """Loud structured audit of a BLOCKED write (FR-5 / D7). + + Logged at ERROR so an ORCH-114-class incident (a pytest mutating a non-sandbox + project) is obvious in the run log, not silent. Never raises. + """ + try: + logger.error( + "plane_write_guard BLOCKED Plane %s write from a test process: " + "project_id=%s work_item=%s reason=%s " + "(ORCH-117 fail-closed; this would have mutated a non-sandbox Plane " + "project from pytest — cf. the ORCH-114 incident)", + op, project_id, work_item_id, reason, + ) + except Exception: # noqa: BLE001 - logging must never raise. + pass + + +def audit_allow(project_id: str | None, op: str, work_item_id: str | None, + reason: str = R_SANDBOX_OPT_IN) -> None: + """Audit (INFO) an ALLOWED real sandbox write from a test process (FR-5 / D7). + + Only the ``sandbox-opt-in`` case is audited here — the ``live-runtime`` ALLOW + (prod/staging) is the normal hot path and is intentionally NOT logged to avoid + spamming the production log. Never raises. + """ + try: + logger.info( + "plane_write_guard ALLOWED sandbox Plane %s write from a test process: " + "project_id=%s work_item=%s reason=%s", + op, project_id, work_item_id, reason, + ) + except Exception: # noqa: BLE001 - logging must never raise. + pass + + +def snapshot() -> dict: + """Read-only view of the guard state (optional observability, D7). Never raises.""" + try: + return { + "in_test_process": _in_test_process(), + "opt_in_enabled": bool(getattr(settings, "plane_test_write_enabled", False)), + "sandbox_allowlist": sorted(_sandbox_allowlist()), + } + except Exception as e: # noqa: BLE001 - never-raise. + return {"error": str(e)} diff --git a/tests/conftest.py b/tests/conftest.py index 7f55a30..66f6a11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,6 +135,34 @@ def _disable_merge_verify(monkeypatch): yield +@pytest.fixture(autouse=True) +def _plane_sandbox_only(monkeypatch): + """ORCH-117: fail-closed FLOOR — no test may write to a non-sandbox Plane project. + + The independent second layer of the sandbox-only Plane-write guard (ADR-001 D5), + by the same model as ``_no_telegram``: it forces the safe defaults for EVERY + test, OVERRIDING any live variable inherited from the container environment. + + With the opt-in OFF, ``src/plane_write_guard.decide`` blocks ALL Plane writes + from the test process (both sandbox and prod) -> default-deny (AC-4). Even if the + runtime leaf ever erroneously returned ALLOW, this floor keeps a prod write from + a plain ``pytest tests/`` impossible. Sandbox-e2e tests that need a REAL write to + SANDBOX re-enable the opt-in in their OWN fixture AFTER this autouse (exactly as + ``test_orch114_*`` / ``test_merge_verify`` re-enable their flags); the allowlist + already contains the SANDBOX id, so the write to SANDBOX passes while a prod write + still blocks (allowlist sandbox-only, AC-3). + """ + 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 + + @pytest.fixture(autouse=True) def _disable_transition_lease(monkeypatch): """ORCH-114: disable the transition-ownership lease + expected-stage CAS by diff --git a/tests/test_orch117_plane_write_isolation.py b/tests/test_orch117_plane_write_isolation.py new file mode 100644 index 0000000..c7ffa24 --- /dev/null +++ b/tests/test_orch117_plane_write_isolation.py @@ -0,0 +1,287 @@ +"""ORCH-117 (adr-0046): sandbox-only fail-closed isolation of Plane WRITES. + +Regression of the ORCH-114 incident: a pytest/worktree process performed a REAL +``PATCH …/issues/… state=`` + comment against the PRODUCTION Plane project, +because test/staging processes inherit the live Plane token and nothing forced them +to write only to the sandbox. This suite pins the fix (``src/plane_write_guard.py`` +врезка in the three ``plane_sync`` write primitives + the conftest floor). + +Covers TC-01…TC-14 (see docs/work-items/ORCH-117/04-test-plan.yaml). httpx is mocked +throughout — there are NO real network calls (a prod write is the very thing the fix +forbids). The autouse conftest fixture ``_plane_sandbox_only`` sets the safe floor +(opt-in OFF, sandbox allowlist = the one SANDBOX id) for the whole suite; ALLOW-path +tests re-enable the opt-in in their own monkeypatch AFTER it (the documented pattern). + +TC-01 is the MANDATORY incident regression: it is RED before the fix (без the +guard врезка the call reaches ``httpx.patch``/``httpx.post``) and GREEN after. +""" + +import logging +import os + +# Match the env-default convention of the other plane suites so config loads cleanly. +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import MagicMock, patch # noqa: E402 + +import pytest # noqa: E402 + +from src import config as _cfg # noqa: E402 +from src import plane_sync as PS # noqa: E402 +from src import plane_write_guard as PWG # noqa: E402 + +# Project ids (verified literals — TRZ §3 / ADR-001 / test-plan notes). +PROD = "7a79f0a9-5278-49cd-9007-9a338f238f9c" # a live (non-sandbox) project. +SANDBOX = "8c5a3025-4f9d-4190-b79f-fa06276bb27e" # the one allowed sandbox project. + + +# --------------------------------------------------------------------------- # +# Helpers / fixtures +# --------------------------------------------------------------------------- # +def _opt_in(monkeypatch, projects: str = SANDBOX): + """Turn the sandbox-write opt-in ON (it is OFF by default via the conftest floor).""" + monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", True, raising=False) + monkeypatch.setattr(_cfg.settings, "plane_test_sandbox_projects", projects, raising=False) + + +def _mock_httpx(): + """Patch ``plane_sync.httpx`` so any patch/post/get is RECORDED, never sent.""" + return patch.object(PS, "httpx", MagicMock()) + + +def _resp_ok(): + r = MagicMock() + r.status_code = 200 + r.raise_for_status.return_value = None + return r + + +@pytest.fixture +def _network_stubs(): + """Stub the network helpers so an ALLOWED write would reach httpx (not the DB/API).""" + with patch.object(PS, "find_issue_id", return_value="issue-uuid"), \ + patch.object(PS, "stage_to_state", return_value="state-uuid"): + yield + + +# --------------------------------------------------------------------------- # +# TC-01 — MANDATORY regression of the ORCH-114 incident. +# --------------------------------------------------------------------------- # +def test_tc01_notify_stage_change_prod_makes_zero_writes(monkeypatch): + """A live prod token in PLANE_HEADERS + pytest + the incident call + ``notify_stage_change('ORCH-114','deploy','done')`` against the prod project -> + ZERO real httpx.patch/post. RED before the guard врезка, GREEN after.""" + # Mirror the incident: a REAL prod token is captured in the module headers. + monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False) + # No opt-in (default floor) — exactly a normal `pytest tests/` run. + with _mock_httpx() as mock_httpx, \ + patch.object(PS, "find_issue_id", return_value="issue-uuid"), \ + patch.object(PS, "stage_to_state", return_value="state-uuid"): + PS.notify_stage_change("ORCH-114", "deploy", "done", project_id=PROD) + + mock_httpx.patch.assert_not_called() + mock_httpx.post.assert_not_called() + + +# --------------------------------------------------------------------------- # +# TC-02 / TC-03 / TC-04 — each write primitive blocks a prod target in-test. +# --------------------------------------------------------------------------- # +def test_tc02_update_issue_state_prod_blocked(monkeypatch, caplog, _network_stubs): + """update_issue_state -> prod project -> httpx.patch NOT called; reason prod-project-in-test.""" + _opt_in(monkeypatch) # opt-in ON so the BLOCK reason is the allowlist, not opt-in-off. + with _mock_httpx() as mock_httpx, caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"): + PS.update_issue_state("ORCH-1", "done", project_id=PROD) + mock_httpx.patch.assert_not_called() + assert PWG.R_PROD_IN_TEST in caplog.text + + +def test_tc03_add_comment_prod_blocked(monkeypatch, _network_stubs): + """add_comment -> prod project -> httpx.post NOT called.""" + _opt_in(monkeypatch) + with _mock_httpx() as mock_httpx: + PS.add_comment("ORCH-1", "hello", project_id=PROD) + mock_httpx.post.assert_not_called() + + +def test_tc04_set_issue_state_direct_prod_blocked(monkeypatch, _network_stubs): + """_set_issue_state_direct (the primitive every set_issue_* funnels into) -> + prod project -> httpx.patch NOT called.""" + _opt_in(monkeypatch) + with _mock_httpx() as mock_httpx: + PS._set_issue_state_direct("ORCH-1", "state-uuid", project_id=PROD) + mock_httpx.patch.assert_not_called() + + +def test_tc04_set_issue_done_prod_blocked(monkeypatch): + """set_issue_done -> _set_issue_state_direct -> prod -> blocked (covers the + public set_issue_* surface, which all reduce to the guarded primitive).""" + _opt_in(monkeypatch) + with _mock_httpx() as mock_httpx, \ + patch.object(PS, "get_project_states", return_value={"done": "done-uuid"}), \ + patch.object(PS, "find_issue_id", return_value="issue-uuid"): + PS.set_issue_done("ORCH-1", project_id=PROD) + mock_httpx.patch.assert_not_called() + + +# --------------------------------------------------------------------------- # +# TC-05 — default-deny: without opt-in, EVERY target (incl. sandbox) is blocked. +# --------------------------------------------------------------------------- # +def test_tc05_default_deny_blocks_sandbox_and_prod(_network_stubs): + """No opt-in (conftest floor) -> sandbox AND prod both blocked.""" + with _mock_httpx() as mock_httpx: + PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX) + PS.update_issue_state("ORCH-1", "done", project_id=PROD) + mock_httpx.patch.assert_not_called() + # Verdict-level: the reason is opt-in-disabled for both. + assert PS.plane_write_guard.decide(SANDBOX, "state")[1] == PWG.R_OPT_IN_DISABLED + assert PS.plane_write_guard.decide(PROD, "state")[1] == PWG.R_OPT_IN_DISABLED + + +# --------------------------------------------------------------------------- # +# TC-06 — sandbox allow: opt-in ON + sandbox project -> real (mocked) write fires. +# --------------------------------------------------------------------------- # +def test_tc06_sandbox_optin_allows_write(monkeypatch, _network_stubs): + """opt-in ON + SANDBOX -> httpx.patch IS called, addressed to the sandbox URL.""" + _opt_in(monkeypatch) + with _mock_httpx() as mock_httpx: + mock_httpx.patch.return_value = _resp_ok() + PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX) + mock_httpx.patch.assert_called_once() + url = mock_httpx.patch.call_args.args[0] + assert SANDBOX in url + assert PROD not in url + + +# --------------------------------------------------------------------------- # +# TC-07 — sandbox-only even with opt-in: a prod target is ALWAYS blocked. +# --------------------------------------------------------------------------- # +def test_tc07_optin_still_blocks_prod(monkeypatch): + """opt-in ON does NOT unlock prod — the allowlist is sandbox-only (AC-3).""" + _opt_in(monkeypatch) + ok, reason = PS.plane_write_guard.decide(PROD, "state", "ORCH-1") + assert ok is False + assert reason == PWG.R_PROD_IN_TEST + + +# --------------------------------------------------------------------------- # +# TC-08 — fail-closed on ambiguity: empty/None target -> block. +# --------------------------------------------------------------------------- # +def test_tc08_ambiguous_target_blocked(monkeypatch): + """opt-in ON but project_id empty/None -> block (NFR-1 'don't know => don't write').""" + _opt_in(monkeypatch) + assert PS.plane_write_guard.decide("", "state")[1] == PWG.R_AMBIGUOUS + assert PS.plane_write_guard.decide(None, "comment")[1] == PWG.R_AMBIGUOUS + assert PS.plane_write_guard.decide(" ", "state")[1] == PWG.R_AMBIGUOUS + + +# --------------------------------------------------------------------------- # +# TC-09 — immune to the import-time token capture (AC-7 / NFR-4). +# --------------------------------------------------------------------------- # +def test_tc09_blocks_regardless_of_captured_token(monkeypatch, _network_stubs): + """A REAL token in PLANE_HEADERS (captured at import) does not help: the guard + decides at CALL time on (test-process + target project), not on the token, and + does not rely on os.environ.setdefault / a settings token swap.""" + monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False) + # No opt-in: a plain pytest run with a live token still cannot mutate prod. + with _mock_httpx() as mock_httpx: + PS.update_issue_state("ORCH-1", "done", project_id=PROD) + PS._set_issue_state_direct("ORCH-1", "state-uuid", project_id=PROD) + mock_httpx.patch.assert_not_called() + # The verdict is token-independent. + assert PS.plane_write_guard.decide(PROD, "state")[0] is False + + +# --------------------------------------------------------------------------- # +# TC-10 — zero regression of the LIVE runtime: not-a-test -> guard is a no-op. +# --------------------------------------------------------------------------- # +def test_tc10_live_runtime_is_noop(monkeypatch, _network_stubs): + """Simulate a non-pytest process -> guard ALLOWs (live-runtime) and the prod + write goes out byte-for-byte (same URL/headers/payload as before ORCH-117).""" + monkeypatch.setattr(PWG, "_in_test_process", lambda: False) + monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False) + with _mock_httpx() as mock_httpx: + mock_httpx.patch.return_value = _resp_ok() + PS.update_issue_state("ORCH-1", "done", project_id=PROD) + mock_httpx.patch.assert_called_once() + args, kwargs = mock_httpx.patch.call_args + assert PROD in args[0] + assert kwargs["headers"] == {"X-API-Key": "LIVE-PROD-TOKEN"} + assert kwargs["json"] == {"state": "state-uuid"} + # The verdict itself is ALLOW/live-runtime. + assert PWG.decide(PROD, "state") == (True, PWG.R_LIVE_RUNTIME) + + +# --------------------------------------------------------------------------- # +# TC-11 — staging runtime (not pytest) writes to SANDBOX normally. +# --------------------------------------------------------------------------- # +def test_tc11_staging_writes_sandbox(monkeypatch, _network_stubs): + """Staging is a real uvicorn process (not pytest) on the sandbox project -> + the test-process detection does NOT fire, the write to SANDBOX passes.""" + monkeypatch.setattr(PWG, "_in_test_process", lambda: False) + with _mock_httpx() as mock_httpx: + mock_httpx.patch.return_value = _resp_ok() + PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX) + mock_httpx.patch.assert_called_once() + assert SANDBOX in mock_httpx.patch.call_args.args[0] + + +# --------------------------------------------------------------------------- # +# TC-12 — audit/observability of block (loud) and allow (info). +# --------------------------------------------------------------------------- # +def test_tc12_block_audited_loudly(monkeypatch, caplog, _network_stubs): + """A blocked write emits a structured WARNING/ERROR carrying project_id / + work_item / op / reason.""" + _opt_in(monkeypatch) + with caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"), _mock_httpx(): + PS.update_issue_state("ORCH-114", "done", project_id=PROD) + blocks = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert blocks, "a block must emit at least one WARNING/ERROR record" + text = caplog.text + assert PROD in text and "ORCH-114" in text + assert PWG.OP_STATE in text and PWG.R_PROD_IN_TEST in text + + +def test_tc12_sandbox_allow_audited_info(monkeypatch, caplog, _network_stubs): + """An allowed sandbox write emits an INFO audit line.""" + _opt_in(monkeypatch) + with caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"), \ + _mock_httpx() as mock_httpx: + mock_httpx.patch.return_value = _resp_ok() + PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX) + infos = [r for r in caplog.records if r.levelno == logging.INFO and "ALLOWED" in r.message] + assert infos, "an allowed sandbox write must emit an INFO audit line" + assert SANDBOX in caplog.text + + +# --------------------------------------------------------------------------- # +# TC-13 — the autouse conftest floor protects the whole suite by default. +# --------------------------------------------------------------------------- # +def test_tc13_conftest_floor_default_deny(): + """Without any per-test opt-in, the floor leaves the opt-in OFF and the sandbox + allowlist pinned to the one SANDBOX id -> a representative write to prod is a + no-op (default-deny is active for every test, not just this file).""" + assert _cfg.settings.plane_test_write_enabled is False + assert _cfg.settings.plane_test_sandbox_projects == SANDBOX + with _mock_httpx() as mock_httpx, \ + patch.object(PS, "find_issue_id", return_value="issue-uuid"), \ + patch.object(PS, "stage_to_state", return_value="state-uuid"): + PS.update_issue_state("ORCH-2", "done", project_id=PROD) + mock_httpx.patch.assert_not_called() + + +# --------------------------------------------------------------------------- # +# TC-14 — kill-switch без чёрного хода (NFR-6 / FR-7 / D4 anti-drift). +# --------------------------------------------------------------------------- # +def test_tc14_no_killswitch_backdoor(monkeypatch): + """There is intentionally NO ``plane_write_guard_enabled`` kill-switch that + re-opens a prod write from pytest. The only reversible regulator is the + sandbox-bound opt-in; even with it ON, prod stays blocked.""" + # Anti-drift: the back-door config key must not exist (a future agent adding it + # would reintroduce the ORCH-114 defect — see ADR-001 D4 / TR-4). + assert not hasattr(_cfg.settings, "plane_write_guard_enabled") + # Opt-in ON is sandbox-bound, never a prod back-door. + _opt_in(monkeypatch) + assert PWG.decide(PROD, "state")[0] is False + assert PWG.decide(SANDBOX, "state")[0] is True diff --git a/tests/test_plane_author.py b/tests/test_plane_author.py index 2b672db..d2a7c4d 100644 --- a/tests/test_plane_author.py +++ b/tests/test_plane_author.py @@ -16,11 +16,27 @@ import os os.environ.setdefault("ORCH_PLANE_API_TOKEN", "shared-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +import pytest # noqa: E402 from unittest.mock import patch, MagicMock # noqa: E402 from src import plane_sync # noqa: E402 +@pytest.fixture(autouse=True) +def _allow_plane_writes(monkeypatch): + """ORCH-117: these tests exercise the write primitives' header/URL routing and + assert on the (mocked) httpx call. The fail-closed sandbox guard (conftest + ``_plane_sandbox_only``) would otherwise block the write in-process (proj is not + a sandbox id + opt-in off). Bypass the guard verdict here so the network-shape + assertions still run; the guard ITSELF is covered by + tests/test_orch117_plane_write_isolation.py.""" + monkeypatch.setattr( + plane_sync.plane_write_guard, "decide", + lambda *a, **k: (True, "test-bypass"), raising=False, + ) + yield + + # --------------------------------------------------------------------------- # # _headers_for # --------------------------------------------------------------------------- # diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py index a330573..acd00d0 100644 --- a/tests/test_plane_status_model.py +++ b/tests/test_plane_status_model.py @@ -15,11 +15,24 @@ import os os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +import pytest # noqa: E402 from unittest.mock import patch, MagicMock # noqa: E402 from src import plane_sync as PS # noqa: E402 +@pytest.fixture(autouse=True) +def _allow_plane_writes(monkeypatch): + """ORCH-117: bypass the fail-closed sandbox write-guard so these layer-B + URL/state-resolution assertions still reach the (mocked) httpx.patch. The guard + itself is covered by tests/test_orch117_plane_write_isolation.py.""" + monkeypatch.setattr( + PS.plane_write_guard, "decide", + lambda *a, **k: (True, "test-bypass"), raising=False, + ) + yield + + # A per-project state map that DEFINES the new ORCH-066 statuses with distinct # UUIDs, so we can prove the dedicated status (not the base alias) is used. _STATES_WITH_NEW = { diff --git a/tests/test_plane_sync_labels.py b/tests/test_plane_sync_labels.py index 365e8d5..b849b2d 100644 --- a/tests/test_plane_sync_labels.py +++ b/tests/test_plane_sync_labels.py @@ -33,6 +33,14 @@ def fresh_cache(monkeypatch): ps.reload_project_labels() monkeypatch.setattr(ps, "_resolve_project_id", lambda w=None, p=None: "proj-1") monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 300, raising=False) + # ORCH-117: the TC-09 set_issue_approved test reaches the guarded write primitive + # with a non-sandbox project ("proj-1"); bypass the fail-closed sandbox guard so + # its (mocked) httpx.patch assertion runs. The guard is covered by + # tests/test_orch117_plane_write_isolation.py. + monkeypatch.setattr( + ps.plane_write_guard, "decide", + lambda *a, **k: (True, "test-bypass"), raising=False, + ) yield ps.reload_project_labels() diff --git a/tests/test_stage_visibility.py b/tests/test_stage_visibility.py index d7be813..35b375f 100644 --- a/tests/test_stage_visibility.py +++ b/tests/test_stage_visibility.py @@ -16,11 +16,24 @@ import os os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +import pytest # noqa: E402 from unittest.mock import patch, MagicMock # noqa: E402 from src import plane_sync as PS # noqa: E402 +@pytest.fixture(autouse=True) +def _allow_plane_writes(monkeypatch): + """ORCH-117: bypass the fail-closed sandbox write-guard so these stage-visibility + PATCH assertions still reach the (mocked) httpx.patch. The guard itself is covered + by tests/test_orch117_plane_write_isolation.py.""" + monkeypatch.setattr( + PS.plane_write_guard, "decide", + lambda *a, **k: (True, "test-bypass"), raising=False, + ) + yield + + EXPECTED_UUIDS = { "architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d", "development": "9920609b-f140-4e46-ab95-89acda8412c8",