diff --git a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md new file mode 100644 index 0000000..494c396 --- /dev/null +++ b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md @@ -0,0 +1,208 @@ +# Отчёт 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 Test` → `Clean Runner Test + (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не + сломалось; если её будут включать — имена проверок брать новые. +- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе + каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией + релиза его нужно запускать явно — `python scripts/release_gate.py + --publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным + заданием.