Назад к блогу

Песочницы для AI-агентов: почему «изолировано» не значит безопасно

Песочницы для AI-агентов: почему «изолировано» не значит безопасно

Статья разбирает, почему контейнерная изоляция не гарантирует безопасность AI-агентов и какие границы на самом деле приходится защищать — от файловой системы и сети до системных вызовов. На примере gVisor (runsc, Sentry, платформа systrap) показано, как устроена более строгая песочница и какие компромиссы заложены в её архитектуре. Материал будет полезен тем, кто запускает агентов в продакшене и хочет понимать разницу между «контейнером» и настоящей изоляцией.

Песочница для агента — это не одно свойство, а набор границ: ядро, файловая система, сеть, идентификаторы, системные вызовы. Разбираем, где именно протекают привычные контейнеры, как устроена изоляция в gVisor (runsc, Sentry, платформа systrap) и какие компромиссы заложены в её архитектуре. Вопрос нетривиален потому, что «контейнер» и «песочница» часто используются как синонимы, хотя за первым стоит программная изоляция внутри общего ядра, а за вторым — граница, проведённая иначе.

Почему контейнера недостаточно

Контейнеры разделяют ядро хоста, а контейнеры на стандартной bridge-сети Docker могут общаться друг с другом. bridge-сеть Docker. По умолчанию контейнер не имеет прямого доступа к файлам хоста, но записываемый bind mount позволяет изменять или удалять файлы хоста, открытые через этот mount. Опора только на контейнер оставляет важные границы незащищёнными, а представление о контейнерах как о заблокированных по умолчанию — крупный риск безопасности.

Это верно только «из коробки»: программную изоляцию уровня контейнера можно защитить и управлять ею политиками. Такая изоляция работает внутри ядра Linux, а границами служат namespaces и пользовательское пространство. Аппаратная изоляция устроена иначе: запускается гостевая VM (microVM/Firecracker) на уровне чипа/CPU, и все процессы выполняются внутри этой выделенной гостевой VM. Компромисс между подходами формулируется как различие между изоляцией на уровне пользовательского пространства и на уровне чипа.

Утечки происходят именно через сетевые и сервисные границы. Агенты OpenAI нашли путь в интернет через внутренний сервис Artifactory, а один агент позже нашёл и поделился публично раскрытыми учётными данными Hugging Face. У Anthropic в трёх оценках кибербезопасности Claude достиг реальных систем, потому что сторонняя тестовая среда имела доступ в интернет вопреки указаниям, что его нет. Агент может работать на ноутбуке, в контейнере или в изолированной гостевой VM на аппаратном уровне — при любом выборе нужны ограничения, применение политик и доступ к LLM и инструментам, но сама сегрегация происходит в разных местах.

Что именно изолируют namespaces

Man-страница namespaces(7) определяет namespace как обёртку глобального системного ресурса: процессы внутри видят собственную изолированную копию. Изоляция не абсолютна: изменения ресурса видны процессам-членам того же namespace, но невидимы остальным. По типам ресурсов: cgroup изолирует корневой каталог cgroup, IPC — System V IPC и очереди сообщений POSIX, network — сетевые устройства, стеки и порты, mount — точки монтирования, PID — идентификаторы процессов, time — загрузочные и монотонные часы, user — идентификаторы пользователей и групп, UTS — hostname и доменное имя NIS.

Создание namespace через clone(2) и unshare(2) в большинстве случаев требует CAP_SYS_ADMIN, поскольку создатель получает власть менять глобальные ресурсы, видимые другим процессам. Исключение — user namespace: с Linux 3.8 привилегий для его создания не требуется. Действуют и лимиты на число namespace: файлы /proc/sys/user/max_*_namespaces задают лимит на пользователя в user namespace, и при его превышении clone(2) и unshare(2) завершаются с ошибкой ENOSPC.

Как runsc поднимает песочницу

Инициализация идёт в строгом порядке. Сначала инициализируются RNG и учёт памяти, при необходимости RDMA-прокси, и RDMA sysfs публикуется в nldev-шим до появления сокетов. Создаётся объект загрузчика с картами процессов и контейнеров, настраиваются GOMAXPROCS и lifecycle CPUs; при наличии FSRestoreFDs параллельно стартует восстановление файловой системы. Затем создаётся платформа, после чего — ядро с платформой, версией драйвера Nvidia, флагом разрешения SUID и поддержкой io_uring.

Всё это выполняет loader.Init: он создаёт платформу, ядро и его ресурсы, а затем поднимает управляющий сервер песочницы. Sentry — это и есть тот процесс, чьё ядро инициализируется на этом шаге.

Дальше создаётся MemoryFile и устанавливается в ядро. На её основе готовится VDSO, создаётся timekeeper с параметрической страницей VDSO, затем корневой сетевой namespace и стек. После этого вызывается инициализация ядра, которой передаются набор возможностей, timekeeper, корневой сетевой namespace, VDSO и её параметры, namespaces и инициализатор cgroup2fs. Регистрируются файловые системы, создаётся watchdog, при необходимости — аргументы процессов для корневого контейнера, затем control server, и только после этого запускается обслуживание RPC с отметкой «control server started».

loader.InitПлатформаKernelMemoryFileVDSOСетевой namespaceNamespacesControl server

Рисунок. Порядок инициализации: платформа и ядро создаются раньше, чем память, VDSO и сетевой стек.

Namespaces при этом не создаются по одному вызову на каждый тип: UTS, IPC и PID создаются от корневого user namespace и передаются в инициализацию ядра вместе с уже готовыми user и network namespace. Mount namespace в этом вызове не создаётся — он передаётся позже, при создании процесса, вместе с сетевым.

Жизненный цикл контейнера и его состояния

Контейнер хранит текущее состояние в поле статуса, которое сохраняется в meta.json. Состояния: Creating (начальное), Created, Running, Paused, Stopped. После создания контейнер переводится в Created, после успешного запуска — в Running. Смена статуса проверяет допустимость перехода и паникует при недопустимом: в Creating переходить нельзя вовсе, в Created — только из Creating, в Paused — только из Running, в Running — только из Created или Paused, а в Stopped можно из любого состояния. Для Created, Paused и Running дополнительно требуется ненулевая песочница, иначе паника «sandbox cannot be nil».

Пауза переводит в Paused только из Created или Running, возобновление — только из Paused обратно в Running. Проверка остановки переводит контейнер в Stopped, когда Sentry сообщает, что runtime-состояние — остановлен. Обработка сигнала контейнеру в состоянии Created устроена отдельно: у такого контейнера нет процесса-получателя, поэтому терминирующий сигнал (SIGKILL/SIGTERM) «исполняется» остановкой контейнера. Внутри сначала перепроверяется статус: если параллельный запуск уже перевёл контейнер в Running или удаление — в Stopped, сигнал доставляется обычным путём. Если статус всё ещё Created, любой сигнал, кроме SIGKILL и SIGTERM, отбрасывается — доставлять его некому. Для терминирующего сигнала при наличии песочницы вызывается уничтожение контейнера (для корневого это SIGKILL песочнице, для подконтейнера — уничтожение ещё не запущенного контейнера в Sentry), после чего статус меняется на Stopped.

Перехват системных вызовов: seccomp, syscall user dispatch, ptrace

Граница между гостем и хостом в gVisor держится на трёх механизмах. Первый — seccomp-фильтры хоста: при старте песочницы устанавливается фильтр, ограничивающий набор разрешённых хостовых вызовов; при отключённом seccomp печатается предупреждение о работе в менее безопасном режиме. Фильтры ставятся до загрузки пользовательского кода. В опции фильтра передаётся набор вендорских вызовов хоста, а также флаги, отражающие конфигурацию: host-сеть, сырые сокеты, доступ к файловой системе хоста, прокси Nvidia, TPU и RDMA, плагинную сеть и файловый дескриптор контроллера.

Второй механизм — syscall user dispatch. Он включается через prctl и, по комментарию в коде, обычно эффективнее seccomp; на старых ядрах вызовы перехватывает seccomp. При неподдерживаемом режиме пишется отладочное сообщение, что вызовы будет перехватывать seccomp.

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

Отдельно проверяется поддержка seccomp-unotify. Проверка идёт вызовом seccomp с флагом нового слушателя: код EFAULT означает поддержку, EINVAL — что механизм не поддерживается ядром (доступен с Linux 5.0). Флаг выбора способа связи с потоками-заглушками определяет, используются ли futex или seccomp-unotify; unotify нельзя применить для процесса-заглушки, порождающего все остальные, потому что с флагом нового слушателя на поток можно установить только один фильтр.

Как из правил собирается BPF-программа

Правила seccomp в runsc описываются декларативно и компилируются в BPF-программу. Сборка начинается с преобразования действия по умолчанию, затем для каждого системного вызова вычисляются действие, аргументы и номер вызова, и правило добавляется в набор. Действие для неверной архитектуры жёстко задаётся как убийство потока. Инструкции компилируются с оптимизацией.

Логика аргументов важна для понимания поведения. Если на один индекс аргумента приходится несколько правил, они объединяются через OR — так же ведёт себя runc. Иначе все аргументы сливаются в одно правило через AND: должны совпасть все условия. Каждое условие — сравнение по индексу аргумента: равно, не равно, больше, больше или равно, меньше, меньше или равно, либо сравнение по маске с двумя значениями. Неизвестный оператор — ошибка.

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

Man-страница seccomp(2) добавляет контекст. В строгом режиме поток может делать только read, write, _exit и sigreturn, прочие вызовы завершают поток (или весь процесс через SIGKILL, если поток один); флаги должны быть нулевыми, аргументы — NULL. В режиме фильтра разрешённые вызовы задаются BPF-программой; для его установки нужен CAP_SYS_ADMIN в user namespace либо уже установленный бит no_new_privs, иначе EACCES. Возвращаемое значение делится на старшие 16 бит — действие — и младшие 16 — данные. Действия по убыванию приоритета: убить процесс, убить поток (он же «убить»), перехватить, вернуть errno, уведомить пользовательское пространство, трассировать, залогировать, разрешить. Флаги: логировать все действия кроме разрешения; вернуть дескриптор уведомлений (не более одного на поток); отключить защиту от Speculative Store Bypass; синхронизировать все потоки процесса на одно дерево фильтров; плюс два дополнительных флага для синхронизации и ожидания.

User ID mappings и почему они зависят от сети

Отображения идентификаторов применяются к процессу с заданным pid внешними программами newuidmap и newgidmap: для каждой записи отображения передаются идентификатор контейнера, идентификатор хоста и размер. Эти программы нужны потому, что непривилегированный процесс не может сам записать произвольные отображения в uid_map/gid_map: ядро строго требует ровно один непрерывный идентификатор, совпадающий с эффективным UID/GID процесса. Если такая непривилегированная запись возможна, отображения выставляются напрямую, а запись setgroups отключается; иначе выставляется флаг, и позже вызывается применение отображений через песочницу — уже после старта gofer.

Зависимость от сети и доступа к хосту прямая: user namespace нужен, когда выбран тип сети host или включён DirectFS. В этой ветке берётся user namespace из спецификации; в rootless-режиме без него возвращается ошибка «unable to run a rootless container without userns». В остальных случаях при rootless-режиме или наличии CAP_SETUID/CAP_SETGID создаётся новый user namespace с отображением идентификатора nobody (65534) на текущего пользователя.

Что видит клиент

При создании контейнера после успешного создания песочницы и подконтейнера записывается PID-файл, если он задан: в него попадает PID песочницы строкой с правами 0644. Это последнее действие перед возвратом контейнера, потому что containerd считает вызов create завершённым только после создания этого файла. Ошибка записи возвращается как «error writing PID file». Клиент получает управление только после записи и может прочитать PID песочницы.

Статистика и трейсы запрашиваются через вызовы в песочницу: сбор данных о памяти формирует параметры с флагом полноты и получает структуру использования памяти; трейс принимает файл и длительность и пишет результат прямо в переданный файл. Вывод песочницы при этом не считается доверенным: после экспорта метрик полученные данные проверяются на соответствие запрошенным опциям, и если они не соблюдены, клиент не получает данные. Это прямо объяснено комментарием: раз вывод песочницы не доверяется как есть, опции перепроверяются.

Пограничные случаи и отказы

Если контейнер не стартовал, а песочница уже завершилась, запуск проверяет это ожиданием завершения не-дочернего процесса с таймаутом 100ms и оборачивает исходную ошибку в специальный тип «песочница завершилась до старта контейнера». К тексту добавляется результат проверки прав бинарника Sentry: требуются бит other-execute для всех платформ и дополнительно бит other-read для платформы ptrace.

Уничтожение песочницы объявлено как быстрое и идемпотентное: удаляется control socket, если путь непустой; при работающей песочнице CPU-лимит переключается с фазы приложения на фазу жизненного цикла, затем процесс убивается и ожидается остановка. Ошибки убийства и ожидания немедленно возвращаются, прерывая освобождение. При создании песочницы уничтожение регистрируется как очистка, чтобы частично созданная песочница убиралась при ошибке; ошибки самой очистки игнорируются.

Мониторинг gofer — процесса, обслуживающего файловую систему контейнера, — устроен так. Выбирается подходящий файловый дескриптор, который должен жить весь жизненный цикл контейнера; если его нет, пишется предупреждение и мониторинг не запускается. Горутина опрашивает дескриптор на события разрыва соединения, переживая прерывания; ошибка опроса — паника. При разрыве получается группа потоков контейнера; если её нет, горутина просто выходит. Иначе в лог пишется «Gofer socket disconnected, killing container» и всем процессам контейнера посылается SIGKILL. Освобождение ресурсов — уменьшение ссылок на общие монтирования, освобождение PID-namespace, освобождение cgroup2, освобождение ядра, закрытие дескрипторов — выполняется отдельно, при уничтожении загрузчика, а не в мониторинге.

При ошибке установки cgroup поведение зависит от режима: в rootless-режиме ошибки EACCES и EROFS не фатальны — логируется предупреждение и работа продолжается без cgroup; прочие ошибки оборачиваются с указанием, что именно конфигурировалось. При ошибке установки родительской cgroup возвращается ошибка, при ошибке подконтейнера родительская cgroup откатывается через удаление. Ошибка установки для root оборачивается как «cannot set up cgroup for root», при успехе в контейнер записывается подчинённая cgroup.

Компромиссы, заложенные в архитектуру

Опция использования давления памяти хоста через cgroup не включается, потому что к моменту создания MemoryFile процесс уже находится в mount namespace, где нужный cgroupfs не виден, — опция бесполезна.

Timekeeping Sentry должен идти в том же TSC-домене, что и гость, чтобы избежать расхождения времени. Поэтому после создания платформы публикуется фактически применённое смещение: оно берётся только у платформ, умеющих смещать гостевой TSC, и передаётся в timekeeping. Если платформа этого не умеет, Sentry остаётся в хостовом TSC-домене, и вызывающий код обязан предупредить о скачке времени. Компромисс: либо платформа даёт согласованный домен и смещение публикуется, либо согласованности нет и остаётся риск скачка, о котором нужно предупреждать.

Политика обработки неубиваемых сигналов отображается из конфигурации в политику ядра: вариант «как в Linux» даёт соответствующую политику ядра, вариант «нет» и значение по умолчанию — отключение. Политика передаётся в ядро при инициализации, а на пути восстановления применяется отдельным методом до запуска ядра.

Что из этого следует на практике

Контейнер и песочница — разные границы. Контейнер разделяет ядро хоста, и его сетевая и файловая изоляция по умолчанию не защищает от соседей и от записываемых монтирований. Если агент получает доступ к сети или к хостовой файловой системе, именно эти границы и протекают — что и показывают случаи с выходом в интернет через внутренний сервис и с реальными системами, достигнутыми из-за доступа тестовой среды в сеть.

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

В gVisor безопасность держится на послойном перехвате: seccomp-фильтры хоста ставятся до загрузки пользовательского кода, гостевые вызовы перехватываются через syscall user dispatch (с откатом на seccomp на старых ядрах), а неудобные вызовы идут через ptrace. Каждый слой имеет условие поддержки, и при его отсутствии поведение меняется — вплоть до менее безопасного режима с предупреждением.

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

Где смотреть в коде

Источники

Похожее