Назад к блогу

Контроль действий AI-агентов в реальном времени, а не их моделей

Контроль действий AI-агентов в реальном времени, а не их моделей

Статья разбирает архитектуру runtime-контроля над AI-агентами: где именно перехватывается вызов инструмента, как формируется вердикт и что происходит, когда механизм проверки недоступен. Вместо оценки «безопасности модели в целом» акцент сделан на конкретном действии в момент его выполнения — с политиками, заморозкой, аудитом и fail-closed поведением. Полезно тем, кто строит или эксплуатирует агентные системы и хочет понимать, как устроен слой принуждения между агентом и внешним миром.

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

Точка перехвата: путь от вызова инструмента до вердикта

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

Дальше возможны три исхода. При нарушении инструмент не выполняется, а вызывающей стороне сообщается статус blocked и флаг mute=True — этот флаг означает, что ошибку не нужно показывать пользователю, а следует подавить её вывод. Если нарушения нет, но включён shadow-режим, инструмент тоже не запускается: возвращается статус simulated с результатом Success (Simulated) и пометкой meta={'shadow': True}. Иначе успех пишется в журнал аудита, и вызов разрешается к выполнению.

Асинхронный вариант делает те же шаги, но сообщает результат через поле allowed: при нарушении allowed=False и mute=True, в shadow-режиме allowed=False и shadow=True, при разрешении — {'allowed': True}.

Отдельная цепочка submit_request → execute работает иначе. submit_request создаёт ExecutionRequest, затем проверяет права и при отказе помечает запрос статусом DENIED — это отказ в правах, запрос дальше не пойдёт. После этого проверяются политики — снова DENIED при отказе. Если проверки пройдены, оценивается риск, и запрос получает статус APPROVED — это значит, что он допущен к исполнению. Метод execute выполняет только запрос со статусом APPROVED; иначе запрос помечается статусом FAILED с ошибкой Request not approved for execution. При выполнении статус меняется на EXECUTING и вызывается диспетчеризация.

Создание и привязка агента

Агент создаётся через create_agent(agent_id, permissions, quota), который создаёт сессию в ядре и, если задана квота, устанавливает её в движке политик. В ядре генерируется session_id как UUID4 и создаётся AgentContext. Контекст сохраняется в таблице активных сессий по session_id.

В пространстве ядра create_agent_context(agent_id) регистрирует агента, создаёт для него диспетчер сигналов и виртуальную файловую систему, а контекст получает ссылку на ядро и уровень защиты RING_3_USER. Идентификацию обеспечивают agent_id, session_id, а также права и квота.

Контекст для ABAC задаётся отдельно через set_agent_context(agent_role, context), где context — словарь атрибутов. Например, is_shadow_mode читает из этого словаря атрибут shadow_mode.

Fail-closed: что происходит при недоступности контроля

Если движок политик отсутствует, _check_policy возвращает (False, "No policy engine configured; denying by default (fail closed)") для любого системного вызова, который не является self-scoped и не разрешён явным permissive-режимом. Для self-scoped вызовов или при _permissive возвращается (True, None).

В syscall() этот результат приводит к SyscallResult с кодом ошибки -2 (нарушение политики) и блокировке вызова. SYS_CHECKPOLICY при отсутствии движка возвращает success=True, но return_value={"allowed": False, "reason": "No policy engine configured; denying by default (fail closed)", ...}, если не включён permissive. При permissive возвращается {"allowed": True}.

Блокируются все вызовы, кроме self-scoped — собственной файловой системы и жизненного цикла. Они остаются разрешёнными, чтобы агент мог использовать свою память и корректно завершиться.

Порядок применения слоёв политики

В validate_request порядок такой: сначала проверяется rate limit, и при неудаче запрос сразу отклоняется с причиной rate_limit_exceeded. Затем перебираются пользовательские правила, и для правила, чей список типов действий содержит тип запроса, запускается его валидатор; если правило не пропускает запрос, он отклоняется с указанием имени сработавшего правила.

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

В validate_risk отказ происходит, если оценка риска не ниже порога deny_above — это настроечный порог из политики риска, задающий максимально допустимую оценку риска запроса, при достижении или превышении которого запрос отклоняется. Отказ также происходит, если какой-то из high-risk patterns встречается в строковом представлении параметров, либо если для URL или домена не удалось извлечь хост, либо если хост совпал с заблокированным доменом.

В check_rate_limit при отсутствии квоты для агента запрос разрешается. Иначе сбрасываются счётчики по минуте (при прошествии не менее 60 секунд) и по часу (не менее 3600 секунд). Затем отказ при превышении минутного лимита, часового лимита, лимита одновременных выполнений или если тип действия не входит в непустой список разрешённых. При успехе минутный и часовой счётчики увеличиваются на единицу.

Условное разрешение при require_all=True требует выполнения всех условий (логическое И), иначе достаточно одного (логическое ИЛИ).

Сборка исполняемой политики из конфигурации

create_policies_from_config принимает либо путь к YAML-файлу, либо готовый объект конфигурации. Если объект не передан, но есть путь — конфигурация загружается из файла; если не передано ничего — создаётся конфигурация со значениями по умолчанию. В любом случае вызывается _build_policy_rules, который собирает список правил.

create_default_policies помечена как deprecated и предупреждает, что встроенные правила — это образцы, не гарантирующие полноту. Внутри _build_policy_rules определены три встроенных валидатора:

  • no_system_file_access для чтения и записи файлов берёт путь из параметров и запрещает доступ, если путь содержит один из опасных путей: /etc/, /sys/, /proc/, /dev/, C:\Windows\System32.
  • no_credential_exposure преобразует параметры запроса в строку в нижнем регистре и запрещает запрос, если в ней встречается одно из слов: password, secret, api_key, token, credential.
  • no_destructive_sql применяется только к запросам и записям в базу данных. При пустом запросе возвращает разрешение. При неудачном разборе SQL срабатывает fail-closed и запрос запрещается. При успешном разборе запрос блокируется, если среди выражений есть DROP; также проверяются ALTER, GRANT, TRUNCATE и другие опасные конструкции.

Заморозка политики и журнал мутаций

Заморозка включается вызовом freeze(), который выставляет флаг _frozen и заменяет изменяемые словари на неизменяемые прокси, чтобы прямое изменение атрибутов тоже вызывало TypeError.

После заморозки запрещены вызовы add_constraint(), set_agent_context(), update_agent_context() и add_conditional_permission(). Каждый из них сначала вызывает _assert_mutable, который при активной заморозке добавляет в журнал мутаций запись с пометкой blocked=True и именем операции, пишет предупреждение в лог и возбуждает RuntimeError. Успешные изменения фиксируются _log_mutation, добавляющим запись с именем операции, деталями, временем и пометкой blocked=False.

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

Правила рисков: атрибут, оператор, значение

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

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

Свободный текст в условиях риска исключён: при проверке домена, если из URL или домена не удаётся извлечь хост, условие считается невыполненным (fail closed). Сопоставление подстрок небезопасно, и политика должна работать по хосту, а не по произвольному тексту.

Проверка сети

Сеть проверяется через извлечение нормализованного имени хоста и сопоставление его с правилами домена. _extract_host принимает URL или строку хоста, разбирает её, берёт свойство .hostname (исключая userinfo и порт), приводит к нижнему регистру, убирает завершающую точку. Если хост не удалось разобрать или он не соответствует регулярному выражению допустимого DNS-имени, хост считается неопределённым.

_host_matches считает хост совпавшим с правилом, если он равен правилу или является его поддоменом:

host == rule_host or host.endswith("." + rule_host)

При этом правило тоже приводится к нормализованному виду, а подстрочное сопоставление намеренно не используется.

В validate_risk при наличии параметров URL или домена извлекается хост; если хост не извлечён — отказ. Затем для каждого заблокированного домена вызывается сопоставление, и при совпадении запрос отклоняется. Проверка разрешённых доменов выполняется только если список непуст: если ни один разрешённый домен не совпал, запрос отклоняется. Пустой список разрешённых доменов не ограничивает домены.

Квоты и rate limit

set_quota сохраняет объект квоты в словаре по ключу идентификатора агента. check_rate_limit берёт идентификатор агента из контекста запроса; если квоты нет, запрос разрешается по умолчанию. При наличии квоты счётчики сбрасываются по времени: минутный — при прошествии не менее 60 секунд с последнего сброса, часовой — не менее 3600 секунд.

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

get_quota_status возвращает словарь со счётчиками и лимитами, а если квоты нет — {"error": "No quota set for agent"}.

Граница ответственности: ядро и шлюз

_check_policy в пространстве ядра выполняет проверку разрешённости системного вызова через движок политик. При отсутствии движка он либо разрешает self-scoped вызовы и permissive-режим, либо запрещает всё остальное по принципу fail closed. При наличии движка он преобразует системный вызов в имя инструмента (для SYS_EXEC берётся имя инструмента из аргументов, иначе — через отображение), добавляет в аргументы имя вызова и уровень защиты и вызывает проверку нарушения.

register_message_security и register_context_router только сохраняют переданные объекты и, если есть реестр, делегируют регистрацию в него. Никаких проверок политики они не выполняют. Таким образом, проверка политики сосредоточена в ядре, а control_plane лишь регистрирует провайдеров безопасности сообщений и маршрутизатор контекста.

Shadow mode

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

_validate_request проверяет статус: если запрос отклонён, возвращается PERMISSION_DENIED с заметкой о недостаточных правах; если оценка риска выше 0.8 — RISK_TOO_HIGH с заметкой о превышении порога; иначе — WOULD_SUCCEED с заметкой, что все проверки пройдут.

_simulate_execution выбирает симулятор по типу действия из словаря (чтение и запись файлов, запрос и запись в базу данных, выполнение кода, вызов API), а для неизвестных типов использует обобщённую заглушку.

Возможные исходы симуляции:

  • WOULD_SUCCEED;
  • WOULD_FAIL;
  • POLICY_VIOLATION;
  • RISK_TOO_HIGH;
  • PERMISSION_DENIED.

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

Вердикт и его обоснование в аудите

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

check_violation при проверке условных разрешений строит контекст оценки из аргументов и контекста агента, добавляет туда верхнеуровневые атрибуты контекста и при невыполнении условий возвращает строку Conditional permission denied for {tool_name}: Conditions not met. Другие ветки возвращают строки вида Role {agent_role} cannot use tool {tool_name}, Path Violation: Cannot access protected directory {protected}, Dangerous pattern detected: {pattern.pattern}, Destructive SQL blocked: {pat} или Internal endpoint blocked: {endpoint}.

_audit добавляет в журнал аудита словарь с временем, типом события и деталями. В flight recorder start_trace создаёт запись со статусом pending и хешами, а log_violation обновляет её, устанавливая вердикт blocked, причину нарушения и пересчитанный хеш содержимого — так обоснование вердикта сохраняется в аудите.

DLP на аргументах и выводе

Проверка аргументов на предмет учётных данных преобразует параметры запроса в строку и запрещает запрос при наличии ключевых слов password, secret, api_key, token, credential.

За контур данные не выпускает контур агента: он реализует быстрые проверки — полномочия, белые списки инструментов, ограничения аргументов, MCP-обёртка, локальные предохранители, запрет при недоступности контроля (fail-closed). Тяжёлые ML-классификаторы на агенте намеренно не запускаются, семантический трафик уходит на шлюз. При отсутствии политики ядро запрещает системные вызовы, выходящие за пределы песочницы агента, по принципу fail closed.

Целостность журнала: Merkle-цепочка

Целостность строится на двух хешах. Первый формирует звено Merkle-цепочки: он склеивает хеш предыдущей записи (или строку genesis, если предыдущей нет) с данными через двоеточие и возвращает SHA-256 от этой строки. Второй хеш считается по всем содержательным полям записи (идентификатор трассы, время, агент, инструмент, аргументы, вердикт, причина нарушения, результат, время выполнения), чтобы ловить подмену полей.

verify_integrity сначала сбрасывает буфер, затем идёт по всем записям в порядке идентификатора и для каждой проверяет, что хеш предыдущей записи совпадает с хешем предыдущей записи в цепочке (для первой записи допускается отсутствие предыдущего хеша как genesis). При нарушении связи возвращается valid=False с идентификатором первой испорченной записи и сообщением о разрыве цепочки. Дополнительно сверяется хеш содержимого с заново вычисленным; при несовпадении возвращается valid=False с сообщением о подмене полей.

Буферизация записей и WAL

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

_flush_buffer под той же блокировкой забирает операции слева из очереди, выполняет их курсором и делает commit(), обновляя время последнего сброса. При ошибке выполняется rollback() и исключение пробрасывается.

WAL включается при инициализации базы данных через PRAGMA journal_mode=WAL, что даёт конкурентное чтение во время записи.

Replay и time-travel

replay_agent_history запускает воспроизведение последних N минут жизни агента: если time-travel выключен или нет отладчика, выбрасывается RuntimeError, иначе вызывается воспроизведение временного окна, а при переданном callback дополнительно вызывается воспроизведение истории отладчика.

capture_agent_state_snapshot сохраняет снимок состояния агента в конкретный момент: при выключенном time-travel просто возвращает управление, иначе формирует словарь из идентификатора сессии, времени создания, прав и метаданных и передаёт его в захват снимка.

get_replay_summary возвращает сводку сессии воспроизведения, а при выключенном time-travel выбрасывает RuntimeError. get_time_travel_statistics возвращает статистику time-travel, а при выключенном режиме — {"enabled": False}.

create_read_only_agent создаёт агента только с правами чтения файлов и запросов к базе данных уровня READ_ONLY и квотой 30 запросов в минуту, 500 в час и списком разрешённых действий из чтения файлов и запросов к базе данных. Поэтому воспроизведение ограничено действиями чтения.

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

  • Контроль встроен в момент действия, а не в модель: любой вызов инструмента проходит через точку перехвата до side effect. Это значит, что безопасность не зависит от того, «насколько хороша» модель — она зависит от того, что вызов физически не может пройти мимо проверки.
  • Отказ по умолчанию — базовое свойство. При недоступности движка политик блокируются все системные вызовы, кроме self-scoped, чтобы агент мог использовать свою память и корректно завершиться. То же касается разбора SQL и извлечения хоста: неудача разбора ведёт к отказу, а не к разрешению.
  • Порядок слоёв задаёт приоритет отказов: сначала rate limit, затем пользовательские правила, затем права, риск, сеть и квоты. Первый отказ прерывает проверку, поэтому причина отказа всегда конкретна.
  • Заморозка политики закрывает атаку самоподмодификации: после неё изменение правил невозможно, а попытки фиксируются в журнале мутаций с пометкой о блокировке. Журнал хранит и успешные изменения — это даёт воспроизводимость вердикта.
  • Свободный текст в условиях риска исключён: решения принимаются по хосту и атрибутам, а не по подстрокам. Это устраняет класс обходов через манипуляцию строками.
  • Разделение семантики: на агенте — быстрые проверки и fail-closed, на шлюзе — семантический трафик и тяжёлые классификаторы. Тяжёлые ML-классификаторы на агенте намеренно не запускаются, чтобы не тормозить сам сценарий.
  • Целостность журнала обеспечивается двумя хешами: цепочкой звеньев и хешем содержимого. Проверка целостности обнаруживает как разрыв связи между записями, так и подмену отдельных полей.
  • Shadow mode позволяет прогонять запросы без side effect: исходы симуляции показывают, что произошло бы, и накапливается статистика по нарушениям политики и отказам по риску.

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

Источники

Похожее