hermes-hub/agents/done/2026-09-02-HUB1-audit-p0-green-main.md
2026-09-03 19:01:30 +07:00

18 KiB
Raw Blame History

Отчёт HUB-1: зелёный main и P0 из аудита

Сдача

Ветка hub/audit-p0-green-main
START_HEAD 93da1b22fd2e0385f46b71a1e220fa1e4e716545
Последний рабочий коммит f061961600dd2eb3d6901cc154dbf5a82456dd14
origin/main на момент сдачи 93da1b2 (не двигался)
PR https://github.com/ochenstarik-ui/hermes-hub/pull/2
Зелёный прогон CI https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525
git status чисто (вне репозитория лежит посторонний gyoza_shorts.mp4, не мой и не тронут)

Зелёный CI — все четыре джоба

Джоб Итог
Clean Runner Test (windows-latest) pass
Clean Runner Test (ubuntu-latest) pass
Headless Run (windows-latest) pass
Headless Run (ubuntu-latest) pass

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 TestClean 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-раннера, то есть релизной джобе нужна матрица. Работа небольшая, но проверяется только настоящей публикацией по тегу — поэтому оставлена за владельцем.