# Отчёт HUB-1: зелёный main и P0 из аудита ## Сдача | | | |---|---| | Ветка | `hub/audit-p0-green-main` | | `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` | | `FINAL_HEAD` | `1982342f42283a6c2be91ceed8d8aa2f6e8a2ace` | | `origin/main` на момент сдачи | `93da1b2` (не двигался) | | PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 | | Зелёный прогон CI на `FINAL_HEAD` | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670815897 | | `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) | ### Зелёный CI — все четыре джоба | Джоб | Итог | Тесты | |---|---|---| | Clean Runner Test (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | | Clean Runner Test (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | | Headless Run (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | | Headless Run (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | `ruff check .` — чисто. Release Gate — PASSED на обеих системах. Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы — 739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён. --- ## P0-1. Зелёный main ### Обе причины из задания подтвердились — и обе оказались шире описания **1. Инвариант A37 не держался на Windows.** Причина именно та, что предполагалась. Воспроизведено локально на окружении Windows (нет переменной `HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается «внутри разрешённого корня» — команда проходит. Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`. `Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и удаление системного каталога Windows выглядело работой внутри проекта. Разбор пути сведён в один конвейер, как и требовало задание: классификация диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог даже когда их нет в окружении → нормализация разделителей → канонизация → сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`. Доказательство: новые тесты воспроизводят окружение обеих систем на любой из них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на Windows-раннере. **2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же `UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output` ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать нельзя. Той же реализацией заменён самодельный блок в `cli_commands`. Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`. ### Причин красного CI было не две, а семь Это главное расхождение с заданием. Ревьюер видел две; живой прогон на Windows после их устранения показал ещё пять. Четыре из них — **не дефекты продукта, а допущения тестов, зашитые под Linux**: 1. `test_a41` читал вывод скрипта в кодировке системы. Скрипт стал писать UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на `UnicodeDecodeError`, оставляя `proc.stdout` равным `None`. Кодировка задана явно с обеих сторон трубы. 2. `test_p0_3_stop_running_hub` знал только про ветку Linux (`os.kill` по списку от `pgrep`). На Windows процессы останавливает `taskkill` по списку от `wmic`. Инвариант один — «чужой процесс останавливается, свой PID не трогаем» — теперь проверяется на обеих ветках. 3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в релизе не найден подходящий файл обновления». Установщик берётся под ту систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3». Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг на cp1252. Тот же класс дефекта, то же лекарство. Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба: `test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик квот от другого теста успевает поднять `generation` между вызовами. Теперь проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние назад не откатывается), а не равенство. Пять полных прогонов со случайным порядком — 777 passed. --- ## P0-2. Остальные P0 аудита — каждый подтверждён исполнением ### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось** Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом оказывался первый в списке — `checksums.txt`, а не пакет. ### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях Измерено прогоном самой функции: | Условие | Прежний вердикт | |---|---| | Полный обрыв сети | **PASS** | | Манифест 404 (релиза нет) | **PASS** | | Пакет 404 (ассет не загружен) | **PASS** | Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза. Разделено, как требовало задание: офлайновая часть (версии, тесты, updater, статика, секреты, список разрешённых адресов) блокирует всегда; Publication Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в обычном прогоне CI, где релиза для ветки нет и быть не должно, результат сообщается как есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за проверенное. Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**. ### 3. localhost `/api/action` без CSRF — **подтвердилось** CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не **отправить** запрос. Измерено на конфигурации по умолчанию (`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а `request.json()` разбирает тело независимо от `Content-Type`. Запрос с `Origin: https://evil.example.com` и без токена доходил до исполнителя действий — отвечало уже само действие. Среди доступных действий `clear_accounts`, `delete_credentials`, `set_main`. Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса запроса. Замер после правки: | Запрос | Итог | |---|---| | чужой сайт, `Sec-Fetch-Site: cross-site` | **403** | | чужой сайт, старый браузер (только `Origin`) | **403** | | собственный интерфейс | 200 | | адресная строка / расширение | 200 | | не-браузерный клиент (curl, CLI) | 200 | Защита распространена на все пять небезопасных методов, не только на `/api/action`. --- ## P1 1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а запись-ссылка легла обычным файлом. CPython санирует пути сам. Но это свойство реализации, а не обещание формата, и распаковка идёт в корень установки. Граница сделана собственным инвариантом: каждая запись проверяется до записи на диск, отклоняются абсолютные пути, выход через `..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не оставлен на усмотрение стандартной библиотеки. 2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump` сериализовал текст обратно в строку, `isinstance(data, dict)` не выполнялось никогда, таблица цен не загружалась ни разу, а `except` это глушил. 3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие Linux-джоба и позволяло четырём платформенным допущениям прятаться; на первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не показывал. 4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web` extra, secret-scan шире) — не трогал, по заданию это отдельные задания. --- ## Ограничения задания — соблюдены - Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил. - Фронтенд не трогал: npm, сборки и фреймворков не добавлено. - Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не тронут. - Учётные данные и `~/.hermes/agy_profiles/` не тронуты. - Версия `0.1.3` не понижена. - Неизмеренное названо причиной: Publication Gate вне режима публикации печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`. --- ## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту Это самое важное из того, что не значилось ни в задании, ни в аудите. **Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних, включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и `test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы выкладывались мимо конвейера. **Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и `update_manifest.json`, а `update_manager` ищет в релизе строго `HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат `HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего не публиковал; **как только я их починил, случайная защита исчезла**: первый же тег привёл бы к публикации «latest» без установщиков, и любая попытка обновиться отвечала бы «В релизе не найден подходящий файл обновления для текущей платформы». Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор содержит установщик и `checksums.txt`, и падает с названной причиной и подсказкой про `installer/build_installer.ps1` и `installer/build_installer_linux.sh`. После публикации добавлен шаг `Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий режим, ради которого ворота и разделялись. Чего я **не** делал: не переписывал сборку установщиков в `release.yml`. Проверить это можно только выкладыванием настоящего релиза по тегу, а это решение владельца, не исполнителя. Конвейер по-прежнему не доходит до публикации — но теперь падает с честной причиной вместо чужой. ## Что стоит решить ревьюеру - Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не сломалось; если её будут включать — имена проверок брать новые. - Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже встроен и блокирует (`--publication-only`, после публикации). Вручную: `python scripts/release_gate.py --publication`. - **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и `installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик требует Linux-раннера, то есть релизной джобе нужна матрица. Работа небольшая, но проверяется только настоящей публикацией по тегу — поэтому оставлена за владельцем.