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

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

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

Зачем ограничивать программу, которая уже в песочнице

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

Поэтому запрет устанавливает доверенная обвязка, которая запускает инструмент. Она должна применить правила до передачи ему управления. Просьба к модели «не меняй файлы» сама по себе прав процесса не уменьшает.

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

В Linux для части таких задач подходит Landlock. Он позволяет процессу ограничить доступные ему действия, в том числе операции с файловой системой. Непривилегированный процесс предварительно включает no_new_privs, чтобы запуск другой программы не мог повысить его права. Набор доступных ограничений определяется версией интерфейса Landlock, его ABI.

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

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

Версии и условия замеров

Хост работал на Linux 6.8.0-138-generic, Ubuntu 22.04, x86-64. Релизы gVisor были 20260817.0 и 20260831.0. Выводы относятся к этим версиям и описанным конфигурациям.

Пробы запускались через тестовую команду runsc do. Основные проверки BPF и сокетного фильтра повторялись через runsc run с OCI-бандлом, то есть с явно заданными файловой системой, пользователем и правами процесса.

В привилегированном профиле набор действующих capability был CapEff=000001ffffffdfff, без CAP_NET_RAW. Для загрузки CGROUP_DEVICE в этих релизах подходит CAP_SYS_ADMIN либо сочетание CAP_BPF и CAP_NET_ADMIN. В непривилегированном профиле использовался CapEff=0, никаких capability у процесса не было.

В этих OCI-проверках NoNewPrivs=0 и Seccomp=0. Они не воспроизводят полный профиль защиты рабочего пода. Для приёмки своей среды нужно повторить проверку с её настройками.

Механизма может не оказаться в среде

В обоих проверенных релизах gVisor все три вызова Landlock возвращали ENOSYS. Даже запрос версии ABI, которому не нужны ни правила, ни пути, ни создание ограниченного процесса.

Для обвязки это ранний отказ. Если она собиралась защищать исходники через Landlock, то в этих запусках такой способ недоступен. Игнорировать ошибку и продолжить запуск означает оставить инструмент без запланированного ограничения.

На хосте запрос возвращал ABI 4. Дополнительно я запустил штатные selftests ядра на хосте и под gVisor. Их результаты пришлось разбирать по причинам отказов.

Под gVisor набралось 194 отказа. Но 179 из них происходили до проверяемой операции. Подготовка тестов упиралась в PR_SET_SECUREBITS, пространства имён и каталог, оставленный предыдущим оборвавшимся тестом. Тело теста после неудачной подготовки уже не выполнялось.

Прямые вызовы подтверждают недоступность Landlock в проверенных запусках. Отсутствие реализации устанавливается отдельно по таблицам вызовов релиза 20260817.0 и релиза 20260831.0. Обработчиков Landlock в них нет, неизвестные номера получают ENOSYS. Большинство отказов selftests в этом прогоне говорит о несовместимости их обвязки.

На хосте остался один отказ другого рода. Тест из upstream v6.8 считал флаг LANDLOCK_CREATE_RULESET_ERRATA недопустимым, а дистрибутивное ядро его принимало. Этот интерфейс позволяет узнать о внесённых исправлениях Landlock. Его перенесли в старую ветку, сохранив ABI 4.

Наличие функции лучше проверять через её интерфейс, чем угадывать по uname. А при отказе теста сначала выяснить, дошёл ли он до проверяемой операции.

Успешная установка ещё не подтверждает запрет

Следующий опыт проверял, можно ли доверять успешной установке запрета. Для этого подошла BPF-программа типа CGROUP_DEVICE, которая управляет доступом процессов к устройствам. Такие правила может устанавливать привилегированная обвязка. В gVisor я включил cgroup v2, механизм группировки процессов, и загрузил программу, запрещающую доступ ко всем устройствам.

Загрузка возвращала настоящий файловый дескриптор, подключение к cgroup завершалось успешно, а последующий запрос показывал одну подключённую программу. Но /dev/null и /dev/zero по-прежнему открывались. Это происходило и в новом процессе, вошедшем в cgroup после подключения фильтра.

Хостовый контроль выглядел иначе. Тестовый дочерний процесс помещался в отдельную cgroup. До подключения программы он открывал /dev/null, после получал EPERM, после отключения снова открывал. Родитель оставался вне этой группы.

Что проверили

Linux на хосте

gVisor с нужными capability

Загрузка CGROUP_DEVICE

Получен дескриптор

Получен дескриптор

Подключение к cgroup

Успех

Успех

Запрос подключённых программ

Программа найдена

Программа найдена

Открытие устройства под запретом

EPERM

Успех

Описание реализации gVisor прямо предупреждало об этом ограничении. Загрузка, подключение и опрос поддерживаются, сами программы не оказывают эффекта. Эксперимент показал, что проверка всех трёх шагов всё равно не подтверждает действие запрета.

Но загрузка такой программы требует дополнительных capability. При CapEff=0 она возвращала EPERM, поэтому процесс не мог самостоятельно выполнить последовательность «загрузить и подключить». Это не исключает установки правил привилегированной обвязкой за непривилегированный процесс. Права того, кто настраивает ограничение, и того, кто под ним работает, могут различаться.

Это результат про контроль доступа к устройствам. Для eBPF-мониторинга нужны другие операции, в том числе создание карт и загрузка программ типа TRACEPOINT. В тех же замерах они отвергались. Готовые средства мониторинга я не запускал.

Фильтр UDP-сокета принимался и без привилегий

Предыдущий опыт требовал прав для загрузки BPF-программы. Возникает вопрос, встретит ли процесс без этих прав похожую ситуацию. Для проверки подошёл классический BPF-фильтр на IPv4 UDP-сокете (AF_INET, SOCK_DGRAM). Он подключается через setsockopt(SO_ATTACH_FILTER) и не требует загрузки eBPF-программы через bpf(2).

По контракту сокетного интерфейса Linux возвращаемый фильтром ноль означает, что входящий пакет нужно отбросить. Для проверки достаточно короткой программы, которая всегда возвращает ноль.

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

На Linux, в хостовом контроле под root, вывод выглядел так. В обоих фрагментах убраны посторонние строки и сокращены отступы.

baseline, no filter: datagram RECEIVED
setsockopt(SO_ATTACH_FILTER, ret #0) ret=0 errno=0 (-)
after classic drop-all cBPF: datagram DROPPED -> EFFECT

В gVisor 20260831.0, при OCI-запуске с --network=host, uid 65534 и нулевым CapEff:

baseline, NO filter attached: datagram RECEIVED
setsockopt(SO_ATTACH_FILTER, classic) ret=0 errno=0 (-)
after classic drop-all cBPF: datagram RECEIVED -> NO EFFECT

До установки фильтра пакет приходит в обеих средах. Установка тоже проходит успешно в обеих. Разница появляется только при следующей отправке. На Linux пробник не получает пакет за время ожидания и печатает DROPPED, в gVisor датаграмма доходит.

В gVisor результат сохранялся при uid 65534 и нулевом CapEff, в том числе при переходе с runsc do на OCI-запуск. В OCI-проверках использовались --network=none с loopback внутри песочницы и --network=host с сетевым пространством имён хоста. На другие типы сокетов этот вывод не переносится. Например, в Netstack релиза 20260831.0 для TCP есть дополнительная проверка CAP_NET_ADMIN.

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

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

Дополнительный контроль со снятием фильтра дал тот же результат без capability на хосте и в обоих релизах gVisor. На Linux пакет приходил до установки и после снятия фильтра, в gVisor приходил на всех трёх шагах.

Как проверить собственную проверку

Один код ошибки ещё не объясняет, что произошло. Это видно по selftests Landlock, которые обрывались до проверки самого ограничения. Даже EINVAL может означать как неподдерживаемую операцию, так и неправильные аргументы пробника.

Для сокетного фильтра я проверил корректную программу и варианты с ошибочными инструкциями. gVisor принимал и те и другие, Linux ошибочные отвергал.

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

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

Для обвязки инструментов из этих опытов получается следующий порядок проверки.

  1. До настройки проверяемое действие выполняется.

  2. Настройка с корректными аргументами принимается.

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

  4. Весь сценарий повторяется в целевой среде. Правила устанавливаются с правами обвязки, а разрешённые и запрещённые действия выполняются с правами и настройками инструмента.

Как применить это к запуску анализатора

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

В одноразовом рабочем каталоге лежит копия исходников. Сначала тест запускается с правами будущего анализатора без дополнительной политики и проверяет, что чтение и изменение файлов доступны. Если запись уже запрещена правами файлов или настройками монтирования, этот опыт не позволит отдельно проверить новый запрет.

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

Действие в копии исходников

До дополнительного ограничения

После ограничения

Прочитать файл

Успех

Успех

Записать в существующий файл

Успех

Отказ, содержимое не изменилось

Укоротить файл

Успех

Отказ, размер не изменился

Создать новый файл

Успех

Отказ

Удалить или переименовать файл

Успех

Отказ, исходный файл на месте

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

Если анализатор запускает дочерние процессы, те же действия нужно проверить после запуска shell или другого дочернего инструмента. Обвязка также не должна передавать ему уже открытые файловые дескрипторы с доступом на запись. Для Landlock наследование ограничений и особенности ранее открытых файлов описаны в документации ядра. Несколько тестовых операций всё равно не доказывают отсутствие всех способов обойти политику.

Требование «исходники не менять» остаётся тем же при смене механизма или рантайма. Меняется способ его обеспечить, поэтому проверку нужно повторить. В рассмотренных релизах gVisor вариант с Landlock остановится ещё при запросе поддержки. Если запрет записи обязателен, до выбора и проверки другого способа анализатор запускать нельзя.

Такой набор удобно запускать при приёмке конфигурации и повторять после изменения ядра, рантайма, образа инструмента, правил, прав или монтирований. Настройки seccomp и no_new_privs тоже входят в конфигурацию. При каждом рабочем запуске обвязка обязана применить проверенную политику и остановиться при ошибке её установки. Результат старого теста применим только пока условия совпадают. Успешный пробник в соседнем процессе сам по себе анализатор не ограничивает.

Что из этого следует

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

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

В документации gVisor рекомендуют проверять совместимость запуском приложения. Для средств ограничения такой запуск должен включать и запрещённое действие. Этот способ проверки можно использовать для других песочниц, но результаты для каждой из них придётся получить отдельно.

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

Код стенда и инструкции по запуску находятся в репозитории.