8.6 KiB
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и вводит новый рантайм-компонент (leafsrc/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):
PLANE_HEADERS/PROJECT_ID(боевой токен + боевой дефолтный проект) захвачены на импорте модуля (стр. 17/57) → подмена env/токена постфактум бесполезна.- Тестовые
os.environ.setdefault("ORCH_PLANE_API_TOKEN",…)— no-op в контейнере с уже установленной боевой переменной. - Все мутации сходятся в три примитива (
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):
not _in_test_process()→ ALLOW (боевой/staging рантайм — no-op, byte-for-byte).project_idнерезолвим → BLOCKambiguous-target(fail-closed, NFR-1).not plane_test_write_enabled→ BLOCKopt-in-disabled.project_id ∉ sandbox-allowlist→ BLOCKprod-project-in-test(sandbox-only даже при opt-in).- иначе → 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