hermes-hub/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md
ochenstarik-ui 1b1143feeb docs(agents): отчёт HUB-1 — зелёный main и P0 аудита
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00

15 KiB
Raw Blame History

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

Сдача

Ветка hub/audit-p0-green-main
START_HEAD 93da1b22fd2e0385f46b71a1e220fa1e4e716545
FINAL_HEAD 9c25a64df8cefa91e03df3224dbec085f6391b96
origin/main на момент сдачи 93da1b2 (не двигался)
PR https://github.com/ochenstarik-ui/hermes-hub/pull/2
Зелёный прогон CI https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670116994
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): 777 passed, 2 skipped, 4 deselected. База до работы — 739 passed, 2 skipped. Число тестов выросло на 38, ни один не удалён.


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.

Что стоит решить ревьюеру

  • Джобы переименованы (Clean Windows Runner TestClean Runner Test (windows-latest)). Защиты ветки на main сейчас нет, так что ничего не сломалось; если её будут включать — имена проверок брать новые.
  • Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией релиза его нужно запускать явно — python scripts/release_gate.py --publication. Имеет смысл добавить этот вызов в release.yml отдельным заданием.