Назад к блогу

AI-агент в проде: песочница, RBAC и egress-контур вместо надежды на промпт

AI-агент в проде: песочница, RBAC и egress-контур вместо надежды на промпт

Когда автономный агент получает право писать в базы, дёргать внешние API и применять манифесты, инструкция в промпте перестаёт быть хоть сколько-нибудь надёжной границей безопасности. Автор раскладывает защиту на три инфраструктурных слоя — песочницу исполнения, узкий RBAC и egress-контур с allow-list — и объясняет, почему при ограниченных ресурсах начинать нужно именно с сети.

Когда агент не только читает кластер, но и пишет в базы, вызывает API и применяет манифесты, вопрос «как его ограничить» перестаёт решаться текстом инструкции. Разберём, почему промпт не может быть границей безопасности и из каких инфраструктурных слоёв собирается контур, который действительно удерживает ущерб.

Почему промпт не является границей

Промпт-фильтр — это тоже LLM, и как регулятор он наследует уязвимости модели, которую охраняет: от prompt steering до jailbreak. Отдельно мешает то, что модель не различает команды и данные: не существует надёжного способа пометить одни токены как команды, а другие — как данные. Системный промпт, запрос пользователя и внешний текст приходят единым потоком, поэтому всё, что попало в контекст, потенциально может управлять агентом.

Отсюда два подхода, которые описывают не текст, а уровни доступа и каналы связи. lethal trifecta — агент, в котором сходятся все три свойства, становится инструментом эксфильтрации после одного внедрённого промпта. Agents Rule of Two — без человека в контуре агенту разрешают иметь максимум два свойства из трёх. Оба подхода говорят об уровнях доступа и каналах связи, и промптами такие ограничения не выразить. Для действующих агентов граница опускается на три уровня ниже текста.

Разница видна на двух типах агентов. Диагностическому read-only-агенту достаточно ServiceAccount с правами только на чтение — граница удерживается на API-сервере. Агенту, который пишет в базы, вызывает API и применяет манифесты, этого мало: когда система умеет находить уязвимости, перечислять запреты поздно, остаётся ограничивать поверхность, до которой она может дотянуться. Поэтому границу опускают в runtime, права и сеть.

Три слоя изоляции: общая картина

Контур образуют три инфраструктурных слоя:

  • Runtime — песочница исполнения, где агент запускается изолированно от хоста.
  • Единица развёртывания — отдельный Pod на агента со своим ServiceAccount без автомонтирования токена, узкой Role и namespace с ResourceQuota.
  • Сеть — default-deny для ingress и egress, выход только через шлюз с allow-list и правила на уровне протокола для доступа к базам и Kubernetes API.

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

Песочница: изоляция как ограничение ущерба

Формулировка «тысячи рабочих нагрузок используют одно общее ядро без структурной изоляции» описывает ситуацию, когда множество нагрузок выполняется на одном ядре, а разделение обеспечивается только политиками и предположением, что весь код написан без ошибок. Структурной границы между нагрузками нет, поэтому сбой одной может затронуть остальные. Браузеры решили это, разведя вкладки по процессам: сбой одной вкладки перестал затрагивать остальные. Обещание подхода — локализовать сбой, а общий принцип сформулирован как containment first. Под сбоем политики здесь понимается ситуация, когда ошибается или оказывается обойдено правило, ограничивающее нагрузку; жёстко заданными считаются границы, обеспеченные структурно — на уровне ядра или виртуализации, а не только декларативно.

CRD Sandbox

Sandbox. Объект Sandbox описывается в манифесте через apiVersion agents.x-k8s.io/v1beta1 и kind Sandbox. Его spec содержит podTemplate — шаблон пода с контейнерами, который и определяет запускаемую рабочую нагрузку. Созданный Sandbox запускает указанный образ, а доступ к нему возможен по стабильному hostname, равному имени Sandbox. Изоляция ядра включается одной строкой в podTemplate:

runtimeClassName: gvisor # вся изоляция ядра - здесь

Цель Sandbox — сильная изоляция, включая изоляцию ядра и сети, что важно для недоверенного кода и многопользовательских сценариев. Для более сложных примеров, включая расширения, есть каталог examples.

gVisor

gVisor ставит между контейнером и хостом userspace-ядро, которое перехватывает системные вызовы. Это ядро называется Sentry. Его наличие означает, что системные вызовы приложения проходят через дополнительные слои ПО. Если container escape всё же случится, атакующий попадёт в userspace-ядро песочницы, а до хоста останется ещё один барьер. Файловые операции при этом маршрутизируются через Gofer.

Kata Containers

Kata Containers решает ту же задачу — изоляцию рабочей нагрузки — но аппаратной виртуализацией: это стандартная реализация лёгких виртуальных машин, которые ощущаются и работают как контейнеры, но дают изоляцию и преимущества безопасности виртуальных машин. Kubernetes SIG развивает CRD Sandbox поверх gVisor и Kata, то есть оба используются как runtime-песочница исполнения в одном слое.

gVisor или Kata: компромисс по производительности

Оверхед gVisor возникает в двух формах: дополнительные циклы и использование памяти. Они могут проявляться как увеличенная задержка, сниженная пропускная способность или плотность, либо не проявляться вовсе. Операции с памятью и выполнение CPU-инструкций идут почти без накладных расходов: gVisor не добавляет затрат на доступ к памяти и не эмулирует выполнение CPU-инструкций. Заметно медленнее оказываются системные вызовы — особенно на платформе ptrace, которая страдает от самых высоких структурных издержек, — а также сеть и файловая система, где преобладают издержки реализации. В redis небольшие операции дают большой оверхед, а более крупные, где больше работы делается в приложении, — меньший относительный. Для файловой системы gVisor вводит небольшие фиксированные накладные расходы на данные, пересекающие границу песочницы, но в большинстве случаев затраты обусловлены реализацией внутренней VFS, которая требует улучшения. Для нагрузки с частой работой с файлами и сетевыми соединениями имеет смысл отдельно протестировать Kata.

Как замерить

Минимальная схема: запустить одного агента на одной задаче в трёх рантаймах — runc, gVisor и Kata — и сделать по полсотни прогонов. Сравнивать стоит p95 wall-clock времени задачи и число системных вызовов через strace -c. Решение принимается по тому, теряется ли разница между runc и gVisor в разбросе времени инференса: если теряется, выбирать уже не из чего.

Прогретый пул

прогретый пул песочниц сокращает холодный старт. По данным Google на KubeCon NA 2025, в такой схеме новая среда готова менее чем за секунду, и компания заявляет до 90% выигрыша по сравнению с холодным стартом. Цифры получены в GKE, но сам принцип не зависит от конкретной платформы. У контроллера есть настройка пополнения прогретого пула — warm-pool refill shaping.

RBAC: права как второй слой

Минимальный набор прав собирается из ServiceAccount (учётная запись для пода), Role (список разрешённых действий) и RoleBinding (привязка роли к этой учётной записи). В примере ServiceAccount report-agent создаётся с automountServiceAccountToken: false, чтобы токен не монтировался в Pod по умолчанию, а Role report-agent-role разрешает только get и list для configmaps — ровно то, что нужно задаче. RoleBinding связывает ServiceAccount с Role в том же namespace.

Поверх ролей нужна ResourceQuota, потому что она ограничивает потребление ресурсов, а не действия: в примере report-agent-quota задаёт requests.cpu: "2", requests.memory: 4Gi, limits.cpu: "4", limits.memory: 8Gi и pods: "5" — потолок на спавн подагентов. Нагрузка идёт волнами, подагенты появляются прямо во время выполнения задачи, поэтому квота ограничивает, сколько ресурсов и подов агент может потребить.

Проверка эффективных прав делается командой:

kubectl auth can-i --list \
--as=system:serviceaccount:agents-report:report-agent

Она покажет реальные права ServiceAccount после применения Role и всех существующих привязок — то, что агент фактически может делать до запуска. Так выявляются лишние права: wildcard-правила и права, выданные через ClusterRoleBinding вместо RoleBinding. Права стоит выдавать точечно — использовать RoleBinding вместо ClusterRoleBinding, где это возможно, и не применять wildcard-правила. Отдельно лишним является смонтированный токен: если агент не работает с Kubernetes API, токен ему не нужен, потому что при успешной инъекции его можно использовать для доступа к API от имени агента.

Egress-контур: сеть как третий слой

Default-deny egress задаётся объектом NetworkPolicy с именем default-deny-all, у которого podSelector пустой ({}), то есть выбирает все поды namespace, а policyTypes включает и Ingress, и Egress — так запрещается всё в обе стороны. Чтобы разрешающее правило сработало, Pod агента должен иметь метку app: report-agent; если метки нет, второе правило не выберет ни одного Pod, и для агента останется в силе общий default-deny.

Первое правило блокирует весь исходящий трафик, в том числе запросы к kube-dns: DNS-имена перестают резолвиться, а в логах появляются таймауты при обращении и к LLM API, и к внутренним сервисам. Поэтому в политике report-agent-egress первым исключением идёт DNS кластера — UDP и TCP порт 53 в namespace с меткой kubernetes.io/metadata.name: kube-system, и это не факультативно, а обязательно. Вторым и единственным разрешённым выходом остаётся egress-шлюз — TCP порт 3128 в namespace с меткой kubernetes.io/metadata.name: agent-egress. Причина в том, что NetworkPolicy умеет ограничивать трафик только по IP-адресам и портам, а агент ходит по HTTPS к доменам, набор которых может меняться.

Шлюз разбирает HTTP(S)-трафик и применяет правила на уровне доменов, а также показывает, куда именно агент обращается наружу. Отдельно от него правила на уровне протокола вводят операции, требующие подтверждения человеком: например, правило для записей в прод-БД содержит поле approve со значением human_approver.oncall — это дежурный инженер, у которого запрашивают подтверждение такой операции. Read-only-учётная запись и правила на шлюзе решают разные задачи: учётная запись определяет то, чего агент не сможет сделать в принципе, а шлюз вводит операции, для которых требуется отдельное подтверждение человека.

Где этот контур не работает

Три технических слоя работают только с теми агентами, которые уже попали в поле зрения платформы. Если агент запущен вне этого контура, защищать там попросту нечего. NetworkPolicy применяется к Pod по меткам, но метка появляется только у того Pod, который кто-то намеренно задеплоил в кластер: скрипт на ноутбуке аналитика, запускающий агента с production-кредами из переменных окружения, под такие правила не попадёт. Это Shadow AI вне Kubernetes. Проблема решается выше уровня Kubernetes-манифестов: через инвентаризацию агентов, контролируемую выдачу доступов и правила, которые не позволяют использовать production-секреты вне утверждённого контура.

Вне границ остаётся и обход через дочерний процесс: один HTTP-прокси легко обойти, если агент может запустить дочерний процесс, например вызвать psql напрямую и не пройти через HTTP-слой. Наконец, deny-list по своей природе неполон: если запретить DROP TABLE, но разрешить DELETE, агент может удалить все строки и формально не нарушить правило — в чёрном списке легко что-то упустить, а агенты хорошо умеют обходить ограничения.

Минимальный контур для production

Целиком контур выглядит так:

  • Runtime: RuntimeClass с gVisor или Kata.
  • Единица развёртывания: отдельный Pod на агента, свой ServiceAccount без автомонтирования токена, узкая Role, namespace с ResourceQuota.
  • Сеть: default-deny для ingress и egress, выход только через шлюз с allow-list, правила на уровне протокола для доступа к базам и Kubernetes API.

Перед полноценным стендом достаточно трёх коротких проверок. Первая — реальные права ServiceAccount через kubectl auth can-i --list с указанием system:serviceaccount:agents-report:report-agent: так видно, не доступны ли агенту лишние действия из-за унаследованных ClusterRole или слишком широких RoleBinding. Вторая — что egress действительно закрыт: зайти в Pod агента через kubectl exec и выполнить curl к домену, которого нет в allow-list; если запрос проходит, политика не работает или не выбрала этот Pod. Третья — наличие runtime командой kubectl get runtimeclass, чтобы выяснить, установлен ли в кластере runtime для gVisor, до указания runtimeClassName в манифесте: иначе Pod может остаться в Pending или не запуститься из-за отсутствующего RuntimeClass.

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

  • Промпт-фильтр не даёт границы безопасности: он наследует уязвимости охраняемой модели, а модель не различает команды и данные. Ограничения по уровням доступа и каналам связи промптами не выражаются.
  • Граница для действующего агента опускается на три инфраструктурных слоя ниже текста: runtime, права, сеть.
  • Если внедрить получается только один слой, начинать надо с сети: утечка данных и удаление базы проходят через песочницу и RBAC, а наружу данные уходят по сети.
  • Read-only-учётка и правила на шлюзе не заменяют друг друга: первая определяет, чего агент не сможет сделать в принципе, вторые вводят операции, требующие подтверждения человека.
  • Изоляция ядра включается одной строкой в podTemplate, но выбор между gVisor и Kata — компромисс по производительности: системные вызовы, сеть и файловая система дороже, а память и CPU-инструкции почти не затронуты.
  • Контур не покрывает агентов вне платформы и обходы через дочерний процесс; deny-list неполон по своей природе, поэтому инвентаризация и контролируемая выдача доступов нужны выше уровня манифестов.

Источники

Похожее