А точно ли вы знаете, чем занят ваш агент в песочнице?

Агент отработал полчаса и остановился. У вас спрашивают, что он делал.
Вы открываете его логи. Там аккуратный рассказ агента о себе: вызвал такой-то инструмент, получил такой-то результат, решил сделать то-то. Рассказ убедительный, потому что писала его та же модель, чьи действия вы проверяете. Вы открываете метрики платформы. Там ровно то, что платформа умеет показывать: столько-то запросов, столько-то секунд, столько-то мегабайт.
Вопрос, на который нет ответа ни в одном из двух окон: откуда вообще берётся знание о том, что агент делал. Не что он про себя написал, а что он сделал.
На чём стоит наблюдение за агентами
Про агентные операционные системы (Agentic OS) заговорили не от хорошей жизни. Если термин раньше не попадался, опорная работа тут AIOS: ядро, которое выдаёт агентам ресурсы, планирует их, управляет памятью и контекстом и раздаёт доступы, то есть ровно тот набор задач, который в обычной системе решает операционная. Когда агент был чат-ботом с поиском, вопрос наблюдаемости не стоял: у него не было рук. Как только агенту дали оболочку, файловую систему и сеть, выяснилось, что мы запускаем в проде программу, которая сама решает, какой системный вызов сделать следующим, и не обязана объяснять зачем.
Дальше есть два способа узнать, что она делает.
Первый способ, спросить саму программу. Так работают все средства трассировки агентов. Они перехватывают вызовы инструментов на уровне фреймворка и пишут, какой инструмент с какими аргументами вызван. Способ дешёвый и врёт ровно в том случае, ради которого он нужен. Если агент делает что-то в обход фреймворка, во фреймворке этого и не будет.
Второй способ, смотреть снаружи, на системные вызовы. Это честнее, потому что мимо ядра не пройдёт никто. Так устроены промышленные средства защиты контейнеров, и точка подключения у всех одна и та же, eBPF на ядре хоста.
Тут есть допущение, которое обычно не проговаривают.
Чтобы наблюдатель на ядре хоста видел действия агента, агент должен исполняться на этом же ядре.
Для обычного контейнера это верно. runc не создаёт никакого ядра, он расставляет пространства имён и группы, а системные вызовы нагрузки обрабатывает то же самое ядро, к которому прицеплен наблюдатель.
Для мультитенантной платформы это перестаёт быть верным, и ломает его требование безопасности. Если на одной машине работают агенты взаимно недоверенных клиентов, общее ядро становится общей поверхностью атаки. Одна уязвимость в нём, и сосед читает чужие данные. Индустрия отвечает на это рантаймом с собственным ядром. У gVisor ядро реализовано в пространстве пользователя, у Kata и Firecracker внутри виртуальной машины работает настоящее ядро Linux. Гостевой системный вызов до ядра хоста не доходит.
Получается требование против требования. Изоляция требует, чтобы между агентом и хостом стояло отдельное ядро. Наблюдение в том виде, в каком оно сейчас построено, требует, чтобы агент исполнялся на ядре хоста.
Что успели стандартизировать, а что нет
Область молодая, но пустой её назвать нельзя. За пару лет тут появилось несколько вполне рабочих стандартов.
Как агент подключается к инструментам и данным, описывает MCP. Гонку он выиграл быстро, сегодня под него пишут и клиенты, и серверы, и обвязки в IDE.
Телеметрию агента стандартизирует OpenTelemetry. В семантических соглашениях для генеративного ИИ есть спаны на операции модели и агента, метрики, события на входы и выходы. Статус пока Development, но экосистема их уже поддерживает.
Выбрать, в чём именно исполняется нагрузка, позволяет RuntimeClass: одно поле в описании пода переключает нагрузку с runc на gVisor или Kata, и приложение об этом не знает.
Появляются и примитивы, придуманные прямо под агентов. В kubernetes-sigs живёт проект agent-sandbox, где песочница становится отдельным ресурсом с постоянной личностью, постоянным хранилищем и возможностью приостановить и продолжить.
Все четыре работают выше ядра или сбоку от него. Разговор агента с инструментом, его собственный рассказ о себе, выбор изоляции, жизненный цикл песочницы. И ни один не описывает, что песочница обязана сообщить наружу о фактически выполненных действиях.
Этого уровня стандарта нет вовсе. Каждый рантайм решает сам. У gVisor есть собственный механизм экспорта событий, Runtime Monitoring, и он богатый. Точки есть на все системные вызовы и важные события, а приёмник вынесен в отдельный процесс рядом с песочницей и изолирован от неё. У рантаймов на виртуальных машинах аналога нет, там предполагается, что вы поставите агента внутрь гостя. Общего интерфейса, который позволил бы средству наблюдения работать одинаково поверх и того, и другого, не существует.
Последствия этого видны в документации средств защиты, и написаны они довольно прямо.
В Falco поддержка gVisor жила отдельным движком. Его объявили устаревшим в 0.43.0 и удалили в 0.44.0. В обосновании две причины: движок мало используется, и gVisor не даёт всей информации, нужной для поддерживаемых типов событий.
Google пишет, что Container Threat Detection несовместим с GKE Sandbox. Песочницу надо выключить на кластерах, где разворачивается детектор.
Microsoft в списке ограничений Pod Sandboxing пишет, что Defender для контейнеров не умеет оценивать поды на рантайме Kata.
Отсюда и вопрос, с которого всё началось. Вы поменяли значение в RuntimeClass. Что после этого изменилось в том, что вы видите снаружи? Документация отвечает словами поддерживается и не поддерживается, а хочется знать, сколько именно и чего: все действия, половина, только имена файлов, ничего.
Такого ответа нет ни у кого, потому что документация отвечает на другой вопрос. Поэтому я собрал стенд и померил.
Дальше выяснилось, что двоичный ответ тут вообще не работает.
Граница проходит не там, где кажется
Как это меряется
Идея замера простая. Внутри песочницы работает генератор, который выполняет операции с уникальным маркером в аргументе: открывает файл с именем вида SVP-<идентификатор прогона>-file-000.dat, пишет в него строку с маркером, удаляет, подключается к порту, исполняет бинарь с маркером в пути. Такое имя не может возникнуть само. На хосте висит наблюдатель на точках трассировки системных вызовов, там же, куда цепляются все перечисленные средства. Если маркер нашёлся в трейсе, операция пересекла границу. Если нет, не пересекла.
Вот как выглядит результат без всякой песочницы. Время, имя процесса, его pid, системный вызов, аргумент:
EVT 96874037790835 gen 2122045 openat op=open SVP-09b4f5231789081827-file-000.dat
EVT 96874037840686 gen 2122045 write op=write SVP-09b4f5231789081827-write-000
EVT 96874037867115 gen 2122045 unlink op=unlink SVP-09b4f5231789081827-file-000.dat
Это опорная точка, с которой сравнивается всё остальное: сам генератор, его настоящие имена файлов, все операции на месте.
Ноль в такой таблице означает две совершенно разные вещи. Либо граница не пропустила операцию, либо наблюдатель в этот момент не работал. Их надо различать, иначе меряется качество собственной обвязки.
Поэтому в каждой строке есть внутренние доказательства.
Самопроверка проб. Отдельная программа делает двенадцать системных вызовов напрямую через syscall(), не через libc, каждый со своим маркером, и требует, чтобы каждая проба поймала свой. Если хоть одна молчит, прогон не запускается вообще.
Хостовая проба. Пока идёт замер, на самом хосте выполняются четыре операции с маркером: открыть файл, записать в него, запустить бинарь, удалить. Песочница на них не влияет, так что в трейсе они быть обязаны. Каждая проверяется отдельно, и если молчит хотя бы одна, повтор негоден. Иначе три живые пробы прикроют четвёртую мёртвую, и её ноль уедет в таблицу как результат.
Маркер в имени образа. Файл диска и ядро гостя называются с тем же идентификатором прогона. Их открывает сам монитор виртуальной машины. Ноль действий гостя при ненулевых открытиях образа означает границу. Ноль и там и там означает сломанную обвязку.
Отрицательный контроль. Та же песочница, тот же идентификатор, ноль действий. Ненулевые маркеры здесь означали бы ошибку отбора.
Знаменатель от генератора. Полнота считается только по операциям, которые гость действительно выполнил успешно, и генератор сам печатает, сколько их было.
Почему я так упёрся в контроли
Потому что без них я больше десяти раз получил уверенное неверное число. Приведу четыре, они поучительные.
Проба висела на unlinkat, а libc на моей машине зовёт unlink. Колонка удалений была пустой, и это выглядело как свойство изоляции.
Проба на запись отбирала строки по вхождению маркера. Генератор пишет свою истину в файл, и каждая её строка содержит маркер. Наблюдатель считал файл истины за измеряемые записи и завышал колонку в семь раз.
Я это починил, заменив вхождение на сравнение первых четырёх байт. В bpftrace str(buf, 4) читает четыре байта вместе с завершающим нулём и возвращает три символа, поэтому сравнение не совпадало никогда. Колонка обнулилась целиком. Пятнадцатисекундная проверка против заведомо пишущего процесса: ноль событий из восьмидесяти у двух вариантов, восемьдесят из восьмидесяти у третьего.
Маркер искался где угодно в трейсе, без привязки к операции. Перед запуском генератор копирует бинарь, и копия открывается по имени с тем же маркером. Колонка запуска считала эти открытия за запуски и показывала десять там, где на самом деле ноль. Колонка удалений просто повторяла колонку открытий, ведь открытие и удаление работают с одним путём, то есть с одним маркером. Лечится тем, что маркер засчитывается только внутри события своей операции.
Три ошибки я внёс сам, когда чинил предыдущую. Лучше всего себя показал вот такой способ: короткий замер с заранее известным ответом на каждую часть обвязки, включая только что исправленную.
Ноль против десяти на одной и той же виртуалке
Теперь собственно числа. Три повтора на среду, 24 прогона. В каждой ячейке количество различных операций из десяти, следы которых дошли до хоста.
Сразу про названия строк. Kata как продукт я не запускал, её в таблице нет. Я взял её сборку, достал оттуда гостевое ядро, qemu и virtiofsd, и собрал руками две конфигурации хранилища, между которыми Kata и предлагает выбирать: общий каталог через virtiofsd и корень гостя на блочном устройстве. Без containerd, без kata-runtime, без агента внутри гостя. Поэтому строка названа по монитору и способу отдать гостю файловую систему, и она говорит ровно то, что измерено.
Firecracker в таблице нужен как второй монитор с тем же гостевым ядром. Если ноль в блочной конфигурации получается и у него, дело в границе, а не в особенностях qemu. gVisor, наоборот, представлен тремя строками одного и того же runsc, здесь интересен не выбор продукта, а его настройки.
Среда | открытие | запись | удаление | запуск | сеть |
|---|---|---|---|---|---|
хост, без песочницы | 10 | 10 | 10 | 10 | 10 |
| 10 | 0 | 0 | 0 | 10 |
| 10 | 10 | 10 | 0 | 10 |
| 10 | 10 | 10 | 0 | 10 |
qemu, корень на virtio-blk | 0 | 0 | 0 | 0 | 0 |
qemu, общий каталог через virtiofs | 10 | 10 | 10 | 0 | 0 |
Firecracker, корень на virtio-blk | 0 | 0 | 0 | 0 | 0 |
Обратите внимание на две строки с qemu. Это одна и та же виртуальная машина. Одно и то же гостевое ядро, один и тот же монитор, одна и та же сеть, один и тот же генератор, одни и те же десять действий, одни и те же привилегии. Отличается ровно одно: в первом случае файловую систему гостя обслуживает блочное устройство и драйвер внутри гостя, во втором её обслуживает процесс на хосте.
Вот куда уходит одно и то же действие гостя в этих двух конфигурациях. Всё, что выше файловой системы, у них совпадает.

Слева имя файла остаётся внутри гостя, его знает только гостевой драйвер, а наружу уходят блоки. Справа имя обязано дойти до хоста, потому что файл открывает процесс на хосте, и открывает он его по имени.
В трейсе это видно буквально. Блочная конфигурация, маркеров гостя ноль, но qemu при этом исправно открывает образ и ядро:
EVT 96762593654801 qemu-system-x86 2120647 openat op=open /var/tmp/svp-vm/disk-SVP-f4277e57...img
EVT 96762602084407 qemu-system-x86 2120647 openat op=open /var/tmp/svp-vm/vmlinux-SVP-f4277e57
Это внутренний контроль строки. Наблюдатель работал, просто видеть ему было нечего. Та же виртуальная машина, но с общим каталогом:
EVT 96768032957818 vring_worker 2120812 openat2 op=open SVP-b1861e4f1789081720-file-000.dat
EVT 96768033338940 vring_worker 2120812 pwritev op=write SVP-b1861e4f1789081720-write-000
По файловым колонкам строка virtiofs совпадает со строками gVisor. Микровиртуалка изолирует заметно сильнее, там настоящее ядро Linux и аппаратная виртуализация, а не перехват вызовов в пространстве пользователя. А видно ровно столько же.
Отсюда вывод. Видимость определяется не силой изоляции. Она определяется тем, проходит ли операция через процесс на хосте. Если файловую систему обслуживает хостовый процесс, имена файлов видны, какой бы сильной ни была изоляция вокруг. Если гость сам себе файловая система на блочном устройстве, в аргументах хостовых вызовов не будет ничего, кроме имени файла образа.
Конфигурация решает больше, чем выбор рантайма
Первая строка gVisor стоит отдельного разбора.
runsc do кладёт поверх хостовой файловой системы оверлей в памяти. В этой конфигурации из всей работы с файлами границу пересекает только открытие, все десять из десяти. Ни записей, ни удалений на хосте нет вообще.
В трейсе это выглядит буквально так. Десять событий, по одному на каждый файл, все от Sentry, и дальше про этот файл ничего. Похоже на единственный поиск по нижнему слою, после которого файл живёт в верхнем, а у верхнего адреса на хосте нет. Для сравнения, у directfs на те же десять файлов приходится тридцать открытий и десять удалений.
Это тот же самый рантайм, что в двух строках ниже. Разница между видимостью всей работы с файлами и видимостью одних только открытий проходит не между продуктами, а между флагами запуска одного продукта.
Для практики это важнее первого вывода. Зная название рантайма, нельзя сказать, что вы увидите. Это выясняется из конфигурации, и выяснять надо заново после каждого её изменения.
Оговорка про название строки. runsc do сам себя называет командой для тестов, и оверлей тут включается её флагом, а не настройкой рантайма. Под containerd gVisor запускают через runsc run с OCI-бандлом, и оверлей там устроен иначе. Так что строка честно говорит про runsc do и ничего не говорит про gVisor в проде.
Запуск не пересекает границу нигде
Колонка «запуск» у всех песочниц одинаковая, ноль. Ни у gVisor, ни у виртуальных машин execve внутри песочницы не становится execve на хосте. Под gVisor бинарь запускает Sentry, в виртуальной машине гостевое ядро, и до точки трассировки на хосте это не доходит ни в том, ни в другом случае.
Но это не значит, что про запуск нельзя узнать ничего. Бинарь перед запуском надо куда-то положить, и там, где файловую систему обслуживает процесс на хосте, копия открывается по имени вместе с маркером. След остаётся, просто это событие открытия, а не запуска. Видно, что появился файл с таким именем, и не видно, что его исполнили.

Строки runc в таблице нет, а именно там гостевой execve был бы хостовым. Так что ноль в этой колонке говорит про песочницы с собственным ядром, а не про контейнеры вообще.
Файлы и сеть делят таблицу по-разному
Последняя колонка выглядит странно. У виртуальных машин ноль, хотя гость подключился успешно все десять раз в каждом повторе, и это записано в его собственном протоколе.
Объяснение не в слепоте. Гость ходит в сеть через устройство tap, то есть пакеты покидают его через дескриптор, а не через системный вызов. На хосте в этот момент происходит не connect, а accept со стороны слушателя. Проба на connect при этом полностью жива, что доказано строкой без песочницы, где она даёт десять из десяти.
Десятка у gVisor тоже держится на конфигурации. Строки сняты с --network=host, то есть песочница пользуется сетевым стеком хоста напрямую. У самого runsc по умолчанию сеть своя, --network=sandbox, и с ней эта колонка обнулилась бы так же, как у виртуальных машин.
То есть для файлов и для сети граница проходит по разным местам, и ответ про видимость зависит от того, про какую операцию спрашивают.
События записаны на рантайм, а не на нагрузку
Вернитесь к строкам выше и посмотрите на третью колонку, имя процесса. Без песочницы там gen, то есть сам генератор. Под gVisor и под virtiofs то же действие выглядит так:
EVT 96887604856796 exe 2122726 openat op=open SVP-93933a20...-file-000.dat
EVT 96768032957818 vring_worker 2120812 openat2 op=open SVP-b1861e4f...-file-000.dat
exe это Sentry, переисполнивший сам себя. vring_worker это рабочий поток virtiofsd. Имя файла на месте, а исполнителем записан служебный процесс рантайма, один и тот же для всего, что происходит внутри песочницы.
То есть про сделанное в событии написано, а про исполнителя нет ничего: ни какому клиенту принадлежит нагрузка, ни на каком шаге агент это сделал. Видно, но не видно кто.
Про это хочу написать отдельно. Интересно разобрать, сколько на самом деле можно восстановить о происхождении события, если не ограничиваться точками трассировки на ядре хоста: у gVisor есть собственный экспорт с идентификатором контейнера и хешем исполняемого файла, у виртуальных машин такого нет, зато можно посадить агента внутрь гостя. Хочется понять, что из этого связывается с шагом агента, чего это стоит и что делать, когда у подложки нужного источника просто нет.
Что с этим делать
Вернёмся к вопросу из заголовка.
По названию рантайма ответить нельзя. Одна и та же виртуальная машина даёт ноль или десять в зависимости от того, кто обслуживает файловую систему. Один и тот же gVisor даёт разное в зависимости от флага. Разброс внутри продукта здесь больше, чем между продуктами.
Одним словом за всю строку тоже не ответить. Возьмите qemu с общим каталогом. Файлы там видны полностью, а сеть не видна вовсе. А запуск процесса не пересёк границу ни в одной песочнице, которую я мерил.
Поэтому спрашивать стоит не про видимость вообще, а каким каналом операция пересекает границу и кому она будет приписана.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.