В прошлой статье про skillmem — локальную память для кодовых агентов — я рассказывал, как чужой README мог стать правилом пользователя, и как мы закрыли это в 0.10. Тогда код прошёл два независимых ревью, и я считал, что дыра одна и она закрыта.
Вчера вышел 0.11.0. Между ними — сорок раундов ревью двумя моделями с разных сторон (одна на стороне Claude, другая на стороне GPT), каждая читала код враждебно и обязана была воспроизвести находку командой, а не рассуждением. Ниже — что из этого вышло и почему я теперь не верю ни одному «чистому» ревью, пока за ним не стоит ещё одно.
Правило стопа
«Ревьюить, пока ошибок не будет» — не терминируется: свежие глаза на любой модуль всегда что-нибудь находят. Мы договорились так: стоп — когда два раунда подряд ни один из двух ревьюеров не воспроизвёл ни одной P1/P2 (потеря данных, неверный ответ, нарушение границы доверия, падение на реальном вводе). P3 копятся в issue следующего релиза.
Правило сработало на раундах 39–40. Дошли мы до них не по прямой.
Три волны находок
Волна 1 (раунды 1–18): исходный аудит. Шесть P1, все не в Stop-хуке, на который все смотрели. HTTP-/write перехватывал чужую запись, /learn писал public-скиллы без права, файлы тел были общими для разных баз, trust можно было выдать любым процессом, kind был path-traversal’ом в экспорте. Закрыли, три раунда чинили то, что сломали фиксы.
Волна 2 (раунды 19–23): репетиция релиза. Я написал ТЗ на релиз и дал его тем же ревьюерам. Репетиция init на копии конфига нашла, что переезд venv удваивает все хуки — рекап шёл бы дважды за сессию. Дальше цепочкой: бэкапы конфигов, названные по секунде, перетирали друг друга; создавались с правами 0644 рядом с файлом, где лежит OAuth-аккаунт; Codex-бэкап был не байт-в-байт. Одиннадцать P2 в коде установки, который «работал».
Волна 3 (раунды 24–40): свежие глаза на ядро. Самое интересное. Каждый раз, когда ревьюер получал модуль, который никто не перечитывал заново, он находил 1–3 старые P2, унаследованные из main:
HTTP-сервер с несколькими агентами: 409 на конфликт цитировал заголовки чужих приватных записей. Бэклинки публичной записи выдавали слаг чужой приватной.
/search,/list,/recallрезали страницу до фильтра видимости — 110 чужих записей, и своя собственная возвращалась пустым 200, хотя/getеё находил.import-vaultшёл по симлинку*.mdнаружу и клал в базу текст цели —~/.zshrc, например. Вложения и паки этот случай уже отвергали; заметки — нет.skills rm <pack>удалял все записи проекта пака, включая авторскую заметку владельца.Windows-планировщик не нёс переменные окружения, которые launchd/cron/systemd несли.
И моё любимое:
scrub— функция, которая прячет секреты перед записью — не была идемпотентной. Уже подставленный[secret redacted]матчился заново при каждой перезаписи и рос в[secret redacted] redacted]. Хеш менялся, одобрение владельца снималось. Restore дампа поверх той же базы снимал одобрения молча.
Каждый второй фикс рождал регрессию
Это главное, что я вынес. Фикс вытеснения в поиске прошёл четыре итерации, и каждую ловил следующий раунд:
Окно кандидатов 5 → фильтр после лимита: пять скрытых строк вытесняют свою.
Окно 100: сто скрытых строк — то же самое.
Без лимита, курсор до N видимых: без
LIMITSQLite сортирует все совпадения до первой строки, и широкий SELECT тащил все тела через сортировку — 20 секунд на запрос на базе в 9 тысяч строк.Узкий проход + тела только для отобранных: 52 мс без фильтра, 73–87 с фильтром. Готово? Нет: предикат отдали и мастеру, и мастер по HTTP стал ранжировать на неограниченном пуле — другой top-5, чем в CLI, для пяти запросов из шести.
Пятая итерация — мастер идёт нефильтрованным путём, и тест «HTTP-мастер == S.search», который падает на предыдущем коммите. Только тогда раунд стал чистым.
Если бы после первого фикса ревью не было — мы бы отгрузили 20-секундный /search с уверенностью, что закрыли утечку.
Что стоит повторить у себя
Два ревьюера с разных сторон, не один. Модели ошибаются по-разному; пересечение их находок за 40 раундов было заметно меньше объединения.
Формат отчёта жёсткий:
P1|P2|P3 · файл:строка · что · evidence (команда + вывод). «Кажется, тут гонка» — это P3, не гейт.Ревьюер не пишет код. Дважды за цикл ревьюер нарушал read-only (правка
pyproject.toml, файл рядом с рабочим клоном) — проверяйтеgit statusпосле каждого раунда.Каждый фикс — новый раунд, даже однострочный. Особенно однострочный.
Стоп-правило назвать заранее, иначе цикл либо не кончится, либо кончится там, где устали.
Цена: сутки машинного времени и ~350 тестов вместо 221. Итог 0.11.0: граница доверия закрыта на HTTP, MCP, CLI и хуках одинаково, и каждая строка release notes проверена командой до того, как была написана. Что осталось P3 — в issue 0.11.1.
pip install skillmem · GitHub · Release notes 0.11.0
Английская версия — на dev.to: https://dev.to/sergey_petrukovich_c94a17/forty-review-rounds-on-code-that-had-already-been-reviewed-46mn
UPD 18.09: статья была неправа, в 0.11.0 нашлись ещё две дыры
Текст заканчивался словами «граница доверия закрыта на HTTP, MCP, CLI и хуках одинаково». Через сутки ревьюеры показали, что нет. Обе дыры давали агенту погасить одобренное правило владельца, и обе не оставляли следа.
Первая: правило уходит из брифинга одним вызовом. Агент вызывает mem_write с другим kind — одобренное правило feedback становится note, покидает инъекцию при старте сессии, одобрение остаётся на месте, в истории ноль строк. Правило просто перестаёт приходить.
Вторая: одобренный текст подменяется под самим одобрением. Тела больше порога живут отдельными файлами, и файл ни с чем не сверялся. Одна запись в файл, без обращения к базе — и trusted_at, content_hash, updated_at и хеш-цепочка целы, а хуки подают новые слова как правило владельца.
Плюс третья, только под Windows: там isatty() истинно для любого символьного устройства, включая NUL, так что агент с stdin=DEVNULL сходил за человека за клавиатурой и добирался до команд владельца. Поймали джобы Windows на релизном коммите.
Всё закрыто в 0.11.1, каждая дыра с тестом, падающим на 0.11.0.
Что я понял про сам процесс
В статье я хвалил правило «два чистых раунда подряд». За следующие 26 раундов оно не выполнилось ни разу — и это важнее самих багов.
Половина находок в поздних раундах была регрессиями от предыдущего фикса. И у всех ровно два шаблона:
Защита ставится у вызывающего, а не в операции. Проверку добавили в обработчик MCP — осталось HTTP. Закрыли
/update— остались/writeи/learn. Закрыли их — остался импортёр, одиннадцатый вызывающий. Помогло только одно: перенести проверку внутрь мутации, через которую проходят все, и сделать её закрытой по умолчанию.Прочитал, потом записал. Между чтением строки и записью владелец успевает одобрить запись, а агент — удалить или подменить. Девять операций стали транзакциями, и каждая запись несёт условие «строка не удалена».
Фичу, ради которой начинался релиз, я удалил
0.11.1 должен был добавить mem_archive — инструмент, которым агент снимает с учёта неактуальную запись. Он дал 13 находок первого уровня за десять раундов, и кривая шла вверх. Инструмент, который убирает запись из всех чтений, сохраняя текст, одобрение и происхождение, работает против границы доверия, внутри которой живёт: каждый гейт был в одном вызове от обхода — напрямую, потом через update, потом через export/import, потом через смену kind, потом через ночную чистку.
Инструмента больше нет. Архивация осталась командой владельца в терминале, инструментов снова девять. Выпустить меньше было правильным ответом, и мне понадобилось десять раундов, чтобы это принять.
И один эксперимент
Половину раундов провёл автономный цикл на сервере: ревью, правки, тесты, коммит — всю ночь без человека, с правилом «если десять раундов подряд всё ещё находят, откатись к началу и начни заново». Он им воспользовался один раз. Итог: 415 тестов вместо 346 и те же два шаблона в находках, что и у человека. Что подтверждает неприятное: дело было не в исполнителе.
pip install -U skillmem · релиз 0.11.1