Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f26908ffc4 | |||
| 0cb121d105 | |||
| 861b5ee984 | |||
| 77d3a66356 | |||
| 8ccbcb3f80 | |||
| 310bebb540 |
13
.env.example
13
.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=
|
||||
|
||||
@@ -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=<Done>` + комментарий «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=<id>`. Покрытие — `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`).
|
||||
|
||||
45
CLAUDE.md
45
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=<Done>` + комментарий «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`, фундамент для будущих
|
||||
|
||||
File diff suppressed because one or more lines are too long
121
docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md
Normal file
121
docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md
Normal file
@@ -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>` + комментарий) —
|
||||
«ложный 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`
|
||||
@@ -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.
|
||||
|
||||
7
docs/work-items/ORCH-117/00-business-request.md
Normal file
7
docs/work-items/ORCH-117/00-business-request.md
Normal file
@@ -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
|
||||
213
docs/work-items/ORCH-117/01-brd.md
Normal file
213
docs/work-items/ORCH-117/01-brd.md
Normal file
@@ -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` (заполняет архитектор).
|
||||
132
docs/work-items/ORCH-117/02-trz.md
Normal file
132
docs/work-items/ORCH-117/02-trz.md
Normal file
@@ -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`.
|
||||
145
docs/work-items/ORCH-117/03-acceptance-criteria.md
Normal file
145
docs/work-items/ORCH-117/03-acceptance-criteria.md
Normal file
@@ -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 (регресс-нейтральность) |
|
||||
113
docs/work-items/ORCH-117/04-test-plan.yaml
Normal file
113
docs/work-items/ORCH-117/04-test-plan.yaml
Normal file
@@ -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
|
||||
@@ -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=<Done>` + комментарий
|
||||
«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`
|
||||
41
docs/work-items/ORCH-117/10-tech-risks.md
Normal file
41
docs/work-items/ORCH-117/10-tech-risks.md
Normal file
@@ -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` не ставится; возврат в анализ не нужен.
|
||||
107
docs/work-items/ORCH-117/12-review.md
Normal file
107
docs/work-items/ORCH-117/12-review.md
Normal file
@@ -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=<Done>` + комментарий) против
|
||||
**боевого** 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` зелёный в полном прогоне.
|
||||
69
docs/work-items/ORCH-117/13-test-report.md
Normal file
69
docs/work-items/ORCH-117/13-test-report.md
Normal file
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
193
src/plane_write_guard.py
Normal file
193
src/plane_write_guard.py
Normal file
@@ -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=<Done>``
|
||||
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)}
|
||||
@@ -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
|
||||
|
||||
287
tests/test_orch117_plane_write_isolation.py
Normal file
287
tests/test_orch117_plane_write_isolation.py
Normal file
@@ -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=<Done>`` + 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
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user