hermes-hub/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md
ochenstarik-ui 922c437689 docs(agents): отчёт HUB-1 — точные FINAL_HEAD и ссылка на прогон
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00

208 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Отчёт 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): **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 Test` → `Clean Runner Test
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
сломалось; если её будут включать — имена проверок брать новые.
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией
релиза его нужно запускать явно — `python scripts/release_gate.py
--publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным
заданием.