Files
orchestrator/docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md

8.6 KiB
Raw Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-117 architecture architect proposed 2026-06-15 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_enabledBLOCK opt-in-disabled.
  4. project_id ∉ sandbox-allowlistBLOCK 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 (ORCH-094), adr-0009 (ORCH-061), adr-0034 (observer-leaf без *_repos)
  • Сверено по коду: src/plane_sync.py:17,57,846-889,1038-1051, tests/conftest.py, scripts/staging_check.py:283