Негативная спека: ваш лучший друг багтрек

В прошлой статье я рассказывал, как искал и помогал исправлять уязвимости в Ginux. Это совместимое с Linux ядро ОС, независимо написанное на Rust. Его автор, Грегори Курцер, участвовал в создании CentOS и основал Rocky Linux. Опыта разработки операционных систем у человека достаточно.

У проекта есть собственная методика — HDAE, Human-Directed Agentic Engineering. Человек задаёт архитектуру, ограничения и условия приёмки, агенты пишут код, проверяют и исправляют. По словам автора, работающее ядро получилось за одиннадцать дней. Задача всё-таки посложнее подсчёта калорий по фотографии обеда.

Я пришёл искать уязвимости со своими rust-in-peace и reproof. Но по мере работы всё чаще замечал: баги возникали на границах, там, где ключевые свойства системы были реализованы лишь частично.

Лимит памяти есть, но часть выделений в него не попадает. Настройку можно включить, но на работу программы она не влияет. Управление возвращается ядру, пока программа обращается к нему через системные вызовы. А если она просто крутится в цикле?

Находки сами попросились на обобщение. В серии «Я созидатель, а ты ССД» мы уже обсуждали, почему спека ещё не гарантирует работающий продукт. Расширим вопрос: что мы забываем написать в спеке, прежде чем отдаём её агенту?

Уязвимости или пробелы в реализации?

В Ginux нашлись и уязвимости, и ошибки совместимости, и недоработанные механизмы защиты. Иногда всё начиналось с проверки простой настройки.

Например, лимит памяти можно было задать, но выделение памяти он не ограничивал. После первых исправлений нашлись расходы через очереди сокетов и overlay filesystem, которые тоже не учитывались. Добавить проверку в одном месте оказалось недостаточно: память выделялась и в других.

В другом случае программа в фоновом Linux-домене могла лишить контроллер управления. Достаточно было бесконечного цикла без системных вызовов. Таймер срабатывал, но переключение зависело от возврата из системного вызова. На программе, которая регулярно обращалась к ядру, это можно было не заметить. А зациклившуюся программу ядро должно прервать само.

Флаг SO_REUSEADDR можно было установить и прочитать обратно. Но на поведение bind()он не влиял. Это ошибка совместимости; сама по себе она ещё не уязвимость. Зато пример наглядный: тест «настройка включилась» проходит, а настройка не работает.

Получается, я искал уязвимости, а часто находил недоработки в базовых механизмах системы. Противоречия здесь нет. Если лимит памяти не действует, это ошибка реализации. Если из-за неё одна программа может забрать всю память и помешать другим работать — это уже проблема безопасности.

Из этого не следует, что баги породила именно HDAE. Возможно, дело в том, как мы привыкли составлять спецификации и чего от них ждём. Обычно спеки позитивные: мы пишем, что хотим видеть в системе. Тому, чего видеть не хотим, внимания достаётся меньше.

Спека была. Чего в ней не хватило?

У Ginux была спецификация: в раннем ARCHITECTURE.md уже описаны домены, изоляция и ограничения ресурсов. Есть требования к подсистемам и тесты. То есть автор не ограничился просьбой «напиши мне Linux».

Но требования «у домена есть лимит памяти» мало. Нужно ещё решить:

  • Какую память учитываем в лимите домена, а какую считаем общей?

  • Где проверяем лимит при создании очереди или файла?

  • Что произойдёт, если один интерфейс проверяет квоту, а другой выделяет ту же память обходным путём?

  • Как откатывается частично выполненная операция?

  • Кто освобождает память после остановки программы?

Тест на создание домена с лимитом на эти вопросы не ответит. Придётся выделять память через разные API, доводить её до исчерпания, прерывать операции и смотреть, что осталось после остановки программы.

Если агент пишет код и тесты по неполной спеке, в обоих местах может не оказаться нужной проверки. Второй агент с той же спекой тоже способен её пропустить. Полезно дать ему сведения о том, как похожие системы уже ломались.

У предметной области багтрек уже есть

При проверке сетевой части я так и делал. Брал подсистему — например, TCP/IP, — изучал историю ошибок и проверял, нет ли похожих проблем здесь.

Это не значит, что в новом стеке непременно найдётся старая уязвимость. Но она подсказывает, где искать. Было переполнение — проверяем расчёт размеров. Обработка очереди съедала процессор — смотрим, что ограничивает её длину и время обработки. Таймер обращался к удалённому объекту — выясняем, кто и когда отменяет таймер.

Получилась негативная спецификация: что не должно случиться, при каких условиях и как мы это проверим. Например, одна программа не должна расходовать память сверх своего лимита, какой бы API она ни использовала.

Сюда же относятся требования к восстановлению после сбоя и к работе под нагрузкой. Зациклившаяся программа не должна блокировать управляющую часть ядра. После отмены операции её ресурсы должны освобождаться. Такие требования помогают заранее решить, что система будет делать при отказе.

Негативные спеки звучат как нарушение корпоративной культуры и пассивная агрессия. Все пришли созидать, а ты опять со своим «что может пойти не так».

Я много работал с промышленной автоматизацией в энергетике и на транспорте. Там к этому относятся иначе. Многие требования в правилах эксплуатации появились после разбора отказов, сбоев, а иногда и катастроф. Они объясняют, чего нельзя допускать и что делать, если оборудование всё‑таки отказало. К этому опыту стоит прислушаться.

В первой статье этой серии я предлагал опираться на опыт: изучать работающий продукт или проверять идеи на прототипе. Но даже до первого прототипа нам доступен чужой опыт.

У вашего нового продукта ещё нет багтрека. У предметной области багтрек уже есть.

В нём описано, как именно случилось то, чего «не может быть». За это знание уже заплатили — часто гораздо больше, чем стоит подписка на модель.

CVE — ещё не требование

Можно скачать список уязвимостей, скормить агенту и попросить «проверь всё это». Для начала исследования сгодится. Для спецификации нужно ещё несколько шагов:

Как ломалась похожая система
    → возможно ли это у нас
    → какое требование отсюда следует
    → как выполним его в коде
    → как проверим
    → где ещё возможна такая ошибка

Важно понять причину ошибки. Запуск старого эксплойта этого не заменяет. Парсер может отвергнуть конкретный пакет, а память всё равно можно будет исчерпать другой последовательностью запросов.

Вот какие требования можно вывести из истории TCP. Это примеры, а не новые находки в Ginux:

Что пошло не так

Требование

Что нужно решить при проектировании

Переполнение при обработке SACK

Расчёт размеров не вызывает переполнения; ошибка не оставляет повреждённые структуры

Где проверяем арифметику и в какой момент меняем данные

Обработка фрагментированной очереди отнимает слишком много времени

Длина очереди и время обработки ограничены

Как выбираем пределы и что делаем при их достижении

Получатель надолго перестал принимать данные

Соединение не удерживает неограниченный объём памяти

Как учитываем память, ограничиваем её расход и закрываем соединение без нарушения протокола

Первые два случая описаны в SACK Panic / SACK Slowness. Проверка переполнения не заменяет проверку времени обработки, и наоборот.

С третьим легко перестараться. TCP допускает длительное нулевое окно: получатель вправе долго не принимать данные. Если просто убивать соединение через произвольные N секунд, можно сломать нормальную работу. Нужно ограничивать расход ресурсов с учётом требований протокола.

Поэтому нужно проверять и атаку, и обычную работу. Защита не должна мешать допустимым действиям. Иначе идеальную защиту от сетевых атак можно получить отключением сети, что скоро, конечно, всех нас ждет, но не хочется. Тесты, возможно, даже пройдут быстрее.

Разумеется, история ошибок не описывает все возможные проблемы. Более того, с ней легко увлечься поиском уже знакомых атак и пропустить другие. Поэтому я использовал три подхода: поиск без подсказок, поиск по модели угроз и поиск по истории уязвимостей.

Очередной велосипед?

Проверки ошибочных действий, моделирование угроз, искусственные сбои и регрессионные тесты существовали задолго до агентной разработки. В CAPEC описано, как использовать известные способы атак при подготовке требований и архитектуры. Есть и современные работы: например, VulInstruct использует прошлые исправления для получения спецификаций безопасности и поиска уязвимостей.

Сам принцип давно известен.

Меня интересует, как встроить его в повседневную работу с агентами. Когда изучать старые ошибки, что добавлять в спеку и как проверять результат?

В шаблонах обычно есть история пользователя, требования, критерии приёмки. Разделы ошибок и граничных случаев тоже встречаются. Но откуда агент возьмёт эти случаи? Один заголовок Edge Cases ему не поможет. Можно заполнить весь шаблон и получить подробное описание того, что хорошее должно быть, а нехорошего быть не должно.

Я посмотрел документацию нескольких популярных фреймворков на начало ноября 2026 года. Работа с ошибками в них предусмотрена, а BMAD прямо предлагает сохранять сведения об уже известных проблемах. Но разбор чужих ошибок можно сделать отдельным шагом перед написанием кода. Вот куда я бы его добавил.

Если у вас Spec Kit

В шаблоне спецификации уже есть сценарии приёмки и граничные случаи. Перед составлением плана я бы дал агенту ещё одно задание: найти несколько ошибок в похожих системах, объяснить, возможны ли они у нас, и предложить требования с тестами.

Если проблема нас не касается, нужно объяснить почему. Утверждение «у нас нет такого состояния» проще проверить, чем «агент ничего не нашёл». Полученные требования должны попасть в спеку и план тестирования. Иначе останется ещё один красивый файл, о котором все забудут.

Если у вас OpenSpec

Spec-driven schema позволяет описывать ошибки и ограничения в сценариях требований. Туда можно добавить, например, исчерпание памяти или отмену операции во время записи.

В design нужно объяснить, как это будет работать: где проверяется лимит, кто отменяет операцию, что происходит с уже записанными данными. В tasks — добавить тесты. Так из старой ошибки получится конкретная задача для разработчика.

Если у вас Kiro

Kiro умеет выделять свойства из требований и проверять их с помощью property-based testing. Сначала добавляем в требования то, что узнали из истории ошибок. Затем проверяем эти свойства на автоматически сгенерированных данных.

Если важного требования в спеке нет, генератор тестовых данных сам его не добавит. И одних входных данных бывает мало: чтобы проверить зависание или гонку при остановке, нужно воспроизвести последовательность действий, иногда с искусственным сбоем. Тысяча успешных прогонов всё равно не доказывает отсутствие ошибок при любых условиях.

Если у вас BMAD

BMAD прямо предлагает сохранять известные ошибки и запреты в контексте проекта. Причём речь идёт о проблемах, которые уже наблюдали, а не просто представили.

Но попросить агента при разработке спеки вывести из чужих ошибок полезные нам инварианты — вполне разумно. Например: отменённая операция не продолжает выделять память, а запрет записи нельзя обойти через другой API. Сначала нужно проверить, подходят ли эти правила нашей системе. Затем коротко записать их в контекст проекта, а рядом сохранить обоснование и ссылки. Пересказывать агенту весь багтрек при каждой задаче незачем.

Если у вас Superpowers или просто TDD

Superpowers проводит агента через обсуждение устройства системы, планирование, TDD и ревью. Старые ошибки можно изучить перед согласованием архитектуры, а перед приёмкой ещё раз попытаться воспроизвести похожие сбои.

TDD помогает реализовать выбранные требования, но сами требования ещё нужно выбрать. Поэтому я бы поручал рецензенту искать то, о чём забыли в спеке, и давал ему историю ошибок вместе с планом. Можно точно выполнить план, в котором не было важной проверки.

Как это сделано в rust-in-peace и reproof

В моих инструментах такой подход уже используется для поиска уязвимостей. В rust-in-peace навык /variant-scan объединяет поиск без подсказок, по модели угроз и по истории ошибок. Он использует в том числе старые исправления: если проверку добавили в одном месте, не забыли ли про другие?

Есть и отдельные инструкции для проверки протоколов. Они предлагают сравнивать клиента с сервером, отправку с приёмом, предложенные параметры с принятыми. Запрет может действовать на одной стороне и отсутствовать на другой. Для такой ошибки падение программы не обязательно.

В reproof есть отдельные этапы проверки находок: воспроизведение в новом контейнере, разбор условий возникновения ошибки, проверка исправления и повторная попытка атаки. Красивого объяснения агента для признания уязвимости недостаточно.

Те же приёмы можно применить раньше — при подготовке спеки. Ниже две заготовки инструкций. Имена условные; это предложения для ваших навыков, а не готовые команды перечисленных фреймворков.

failure-history: получить требования из истории отказов.

Вход: подсистема, требования к ней, архитектура и ограничения задачи.

Найди несколько ошибок в похожих системах. Используй первичные
источники: отчёты об уязвимостях, issues, исправления и стандарты.
Выбери ошибки с разными причинами. Длинный список CVE не нужен.

Для каждой укажи:
- ссылку на источник, причину ошибки и условия её возникновения;
- возможна ли такая ошибка у нас и почему;
- какое требование нужно добавить и где его реализовать;
- как проверить ошибку и убедиться, что защита не мешает обычной работе;
- чего мы пока не знаем.

Сходство с чужой ошибкой ещё не доказывает наличие нашей.
Предложи правки спеки, архитектуры и плана тестирования.

challenge-spec: найти пропущенные требования и проверки.

Вход: спека, архитектура, история ошибок и план тестирования.

Для каждого важного ограничения проверь, нельзя ли его обойти:
через другой API, передачу ресурса другой программе, повтор запроса,
отмену или перезапуск. Что будет, если операция выполнится частично,
закончится память или программа зациклится?

Укажи, где проверяется ограничение и что пока не проверено.
Предложи простой тест и опиши ожидаемый результат.
Объясни, как по результату понять, что требование выполнено.

Отделяй найденные ошибки от недостающих требований и предположений.
Для каждого замечания напиши, что нужно сделать и как проверить результат.
Не делай вывод «ошибок нет» только потому, что не смог их найти.

Эти задания можно дать одному агенту по очереди или разделить между автором и рецензентом. Само по себе увеличение числа агентов не поможет, если никто из них не читает историю ошибок.

Для начала я бы взял одно требование. Например: после отмены задания агент не запускает новые инструменты. Отдельно проверил бы, что происходит с уже запущенными задачами и очередью. Толку от этого больше, чем от пятидесяти страниц общих угроз.

И кто проверит проверяющего?

В моём же PR к Ginux пришлось исправлять сам тест. Он сравнивал время работы двух доменов по часам, у которых не было общей точки отсчёта. Из такого сравнения нельзя было сделать нужный вывод. Тест переделали, а прежние результаты пришлось отозвать.

Приятного мало. Зато напоминание: зелёная галочка полезна, только если мы понимаем, что именно проверили.

Там же возник вопрос воспроизводимости. Общие тесты проходили, но мейнтейнер хотел сам проверить новую функцию. Моего «у меня работает» оказалось мало. Пришлось подготовить исходники тестовых программ, команды сборки и запуска, а затем перенести проверки в доступный мейнтейнеру CI.

Полезно запускать тест на версии до исправления: ошибка должна воспроизводиться там и исчезать после патча. Если старая версия просто не загрузилась, это не считается воспроизведением зависания. Нужен и контрольный тест с программой, которая регулярно делает системные вызовы: с ней переключение должно работать на обеих версиях.

В тесте тоже может быть ошибка. Иногда сначала приходится чинить его, а уже потом проверять патч.

Что из этого приехало в ASAMM

Эти выводы приехали в новую версию ASAMM — Agentic SAMM. Теперь при подготовке требований учитываем историю отказов. Для важных ограничений проверяем, где они реализованы и чем протестированы. После исправления ищем, не осталось ли такой же ошибки в других местах.

Практически это сводится к четырём вопросам:

  1. Откуда взялось требование? Из пожелания, стандарта, разбора старой ошибки? Почему оно подходит нашему проекту?

  2. Как оно работает? Инструкция «не делай так» и код, который не даёт так сделать, — разные вещи. Проверяем, нельзя ли обойти запрет через другой интерфейс.

  3. Почему мы верим тесту? Что он проверяет, как отличает ошибку от правильной работы, сможет ли другой человек повторить результат?

  4. Что изменилось после находки? Помимо патча добавляем тест, уточняем требования и проверяем похожие участки кода.

По одному багу TCP нельзя оценить весь процесс агентной разработки. ASAMM помогает проверить, как команда ставит задачи агентам и принимает их работу. Корректность самого ядра придётся проверять тестами и ревью.

Багтрек до кода

Я всё ещё хочу писать хорошие спецификации. Теперь в них должно быть место и для того, как похожие системы уже ломались.

Начать можно с одной подсистемы: найти несколько старых ошибок, разобрать причины и добавить в спеку требования с тестами. А потом посмотреть, повлияло ли это на архитектуру и код. Если вся работа закончилась новым разделом в Markdown, пользы пока немного.

Что хорошего мы хотим от продукта, обычно и так понятно. За примерами плохого можно сходить в багтрек. Было бы обидно поручить агенту быстро повторить ошибки, которые другие уже нашли и исправили.

Сергей Гордейчик, СайберОК/SCADA Strangelove

Автор: sgordey

Источник

Оставить комментарий