docs(agents): отчёт HUB-1 — зелёный main и P0 аудита
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1fe4549b22
commit
1b1143feeb
1 changed files with 208 additions and 0 deletions
208
agents/reports/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
208
agents/reports/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
|
|
@ -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` отдельным
|
||||
заданием.
|
||||
Loading…
Reference in a new issue