Безопасность агентных систем
Гонки состояний при ручном подтверждении действий AI-агента
Кнопка «Подтвердить» безопасна только тогда, когда однозначно фиксирует, какое действие, над какими данными и при каких правах разрешил пользователь. Если агент после подтверждения заново собирает параметры или продолжает изменившийся план, пользователь фактически одобряет одно, а система исполняет другое.
Где возникает разрыв
Гонка состояний здесь — это зависимость результата от того, что успело измениться между подготовкой операции, решением пользователя и фактическим исполнением.
Типичный небезопасный поток выглядит так:
- Агент читает объект, строит план и показывает предложение.
- Пользователь несколько минут изучает запрос.
- За это время объект редактируют, права отзывают, политика меняется или агент перестраивает план.
- Исполнитель получает только идентификатор подтверждения и заново извлекает текущие параметры.
- Выполняется операция, которую пользователь не видел в окончательном виде.
Это частный случай TOCTOU: проверка выполняется в один момент, а использование проверенного состояния — в другой. Само наличие человека в контуре проблему не устраняет; длинная пауза, наоборот, расширяет окно гонки.
Что именно требуется связать с подтверждением
Подтверждение должно ссылаться не на «текущий план агента», а на неизменяемый снимок операции. Минимальный конверт подтверждения содержит:
- уникальный
approval_id; - идентичность пользователя и субъекта, от имени которого пойдёт запрос;
- точное имя инструмента или операции;
- канонизированные аргументы без изменяемых ссылок вроде «последняя версия»;
- идентификаторы и версии исходных объектов;
- версию плана и политики;
- описание ожидаемого эффекта, показанное человеку;
- время создания и короткий срок действия;
- криптографический хеш всего конверта;
- ключ идемпотентности для защиты от повторного исполнения.
{
"approval_id": "appr-example-001",
"actor_id": "user-example",
"tool": "document.update",
"arguments": {
"document_id": "doc-example",
"expected_version": 17,
"patch": [
{"op": "replace", "path": "/status", "value": "reviewed"}
]
},
"plan_version": 4,
"policy_version": "policy-example-v3",
"created_at": "2030-01-01T10:00:00Z",
"expires_at": "2030-01-01T10:05:00Z",
"idempotency_key": "appr-example-001:execute"
}
Это демонстрационный конверт, а не описание конкретного API. Значения намеренно вымышлены и не являются клиентскими данными, учётными записями или секретами.
Воспроизводим проблему локально
Следующий пример работает только с локальными переменными и ничего не изменяет во внешних системах. Он моделирует объект, который обновился во время ожидания подтверждения.
python3 - <<'PY'
record = {"id": "doc-1", "version": 7, "status": "draft"}
proposal = {
"document_id": record["id"],
"expected_version": record["version"],
"new_status": "published",
}
print("Показано пользователю:", proposal)
# Конкурирующее изменение во время ожидания.
record["version"] = 8
record["status"] = "archived"
# Небезопасный исполнитель игнорирует expected_version.
record["status"] = proposal["new_status"]
print("Небезопасный результат:", record)
PY
Результат показывает version: 8 вместе со статусом published: операция была подготовлена для версии 7, но применилась к уже изменённому объекту. Проверка только идентификатора документа этого не обнаруживает.
Безопасный протокол подтверждения
1. Заморозьте действие до показа пользователю
Канонизируйте аргументы, сохраните их на сервере и вычислите хеш. Интерфейс должен получать снимок из хранилища подтверждений, а не повторно генерировать описание из текущего состояния агента.
import hashlib
import json
def canonical_bytes(envelope):
return json.dumps(
envelope,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def digest(envelope):
return hashlib.sha256(canonical_bytes(envelope)).hexdigest()
Хеш обнаруживает изменение полей, но не доказывает происхождение данных сам по себе. Доверенная серверная сторона должна хранить эталонный снимок или подписывать его серверным ключом. Секреты и токены доступа в конверт и браузер передавать нельзя.
2. Подтверждайте конкретную ревизию
Запрос подтверждения должен включать approval_id и хеш показанного снимка. Сервер атомарно переводит запись из pending в approved только при совпадении обоих значений.
UPDATE approvals
SET status = 'approved',
approved_by = :current_user_id,
approved_at = CURRENT_TIMESTAMP
WHERE approval_id = :approval_id
AND envelope_hash = :displayed_hash
AND status = 'pending'
AND expires_at > CURRENT_TIMESTAMP;
Успехом считается ровно одна обновлённая строка. Ноль строк означает истечение срока, повторное нажатие, отзыв или несовпадение снимка. Клиент не должен превращать такой результат в безусловный повтор.
3. Перед исполнением повторите динамические проверки
Некоторые свойства нельзя заморозить: права могли быть отозваны, объект — заблокирован, а политика — усилена. Поэтому исполнитель заново проверяет динамические ограничения, но не подменяет ими одобренные аргументы.
Перед побочным эффектом проверьте:
- запись подтверждения всё ещё имеет состояние
approvedи не истекла; - хеш сохранённого конверта совпадает;
- пользователь или сервисный субъект всё ещё имеет нужное право;
- текущая версия каждого объекта равна
expected_version; - план, инструмент и область действия не изменились;
- операция с данным ключом идемпотентности ещё не была выполнена.
При любом расхождении корректный результат — stale или reapproval_required. Агент может построить новое предложение, но не переносить старое согласие на новую ревизию.
4. Свяжите проверку версии с записью результата
Проверить версию отдельным чтением недостаточно: между SELECT и UPDATE снова остаётся окно гонки. Используйте условное обновление, compare-and-swap или транзакционную блокировку, соответствующую вашей базе данных.
UPDATE documents
SET status = :approved_status,
version = version + 1
WHERE document_id = :document_id
AND version = :expected_version;
Если изменена не одна строка, побочный эффект не состоялся. Исполнитель помечает попытку как конфликтную и запрашивает новое подтверждение после повторного чтения.
5. Сделайте потребление подтверждения однократным
Для локальной транзакции можно атомарно перевести approved → executing → executed. Для удалённого API добавьте стабильный ключ идемпотентности и журнал попыток. Не генерируйте новый ключ при сетевом повторе того же действия: это превратит повтор доставки в повторный эффект.
approval:
ttl_seconds: 300
bind:
- actor_id
- tool
- canonical_arguments
- resource_versions
- plan_version
- policy_version
require_current_authorization: true
require_compare_and_swap: true
consume_once: true
on_mismatch: require_new_approval
on_expiry: require_new_approval
Это пример конфигурации с условными именами параметров. Его следует адаптировать к реальной схеме, а не копировать как конфигурацию несуществующего продукта.
Проверяем защиту
Локальная модель ниже демонстрирует позитивный сценарий и отказ после конкурентного изменения. Команда не использует сеть и не записывает файлы.
python3 - <<'PY'
def execute(record, proposal):
if record["id"] != proposal["document_id"]:
return "rejected: wrong resource"
if record["version"] != proposal["expected_version"]:
return "reapproval_required: stale version"
record["status"] = proposal["new_status"]
record["version"] += 1
return "executed"
initial = {"id": "doc-1", "version": 7, "status": "draft"}
proposal = {
"document_id": "doc-1",
"expected_version": 7,
"new_status": "published",
}
unchanged = initial.copy()
print(execute(unchanged, proposal), unchanged)
changed = initial.copy()
changed["version"] = 8
changed["status"] = "archived"
print(execute(changed, proposal), changed)
PY
Ожидаемое наблюдение:
executed {'id': 'doc-1', 'version': 8, 'status': 'published'}
reapproval_required: stale version {'id': 'doc-1', 'version': 8, 'status': 'archived'}
Во втором случае состояние остаётся archived. Это ключевая проверка: конфликт не просто регистрируется после исполнения, а предотвращает побочный эффект.
В рабочей системе дополнительно воспроизведите контролируемые сценарии в тестовом окружении:
- измените версию ресурса после создания запроса и убедитесь, что исполнение отклонено;
- отзовите право после подтверждения и проверьте повторную авторизацию;
- дождитесь TTL и убедитесь, что старое подтверждение нельзя оживить обновлением страницы;
- дважды отправьте один запрос исполнения и проверьте единственный побочный эффект;
- измените один аргумент или шаг плана и убедитесь, что хеш перестал совпадать;
- одновременно запустите две попытки потребления и проверьте, что победила только одна.
Типовые ошибки
- Подтверждать только текстовое описание
- Фраза «обновить документ» не связывает согласие с идентификатором, версией, патчем и эффектом. Подтверждать нужно структурированный конверт, а текст должен быть его представлением.
- Передавать окончательные аргументы из браузера
- Клиентский запрос можно изменить. Браузер должен ссылаться на серверный снимок и подтверждать его хеш, а исполнитель — брать аргументы только из доверенного хранилища.
- Считать хеш электронной подписью
- Обычный SHA-256 обнаруживает различие только при наличии доверенного эталона. Если атакующий может заменить и документ, и хеш, защита исчезает.
- Проверять права только при показе диалога
- Отзыв доступа должен вступать в силу до исполнения. Повторная авторизация обязательна и должна учитывать текущий ресурс и точное действие.
- Автоматически «освежать» устаревшее предложение
- Новая версия объекта означает новое действие. Интерфейс обязан показать различия и запросить отдельное согласие.
- Разрешать агенту продолжить изменившийся план
- Подтверждение одного шага не является разрешением на новые инструменты, получателей или область данных. Существенное изменение плана инвалидирует согласие.
- Повторять запрос с новым ключом идемпотентности
- После тайм-аута результат может быть неизвестен. Сначала запросите состояние исходной попытки или повторите её с тем же ключом.
- Смешивать аудит и контроль исполнения
- Журнал помогает расследовать событие, но запись «пользователь подтвердил» не предотвращает гонку. Нужны атомарные условия непосредственно на пути записи.
Ограничения
Универсальной транзакции между вашей базой и внешним сервисом обычно нет. Условное обновление защищает локальный ресурс, но не гарантирует атомарность удалённого побочного эффекта. Для таких интеграций нужны идемпотентный API, transactional outbox, конечный автомат попыток или компенсирующая операция — в зависимости от риска.
Версия объекта также не всегда отражает всё значимое состояние. Решение может зависеть от состава группы, лимита, курса, политики или связанного ресурса. Эти зависимости следует либо включить в снимок и версионировать, либо повторно проверить непосредственно перед исполнением.
Короткий TTL уменьшает окно риска, но не заменяет проверку версии. Длинный TTL удобнее пользователю, однако увеличивает вероятность устаревания. Для необратимых операций полезны более короткий срок, явный предпросмотр различий и дополнительное подтверждение непосредственно перед эффектом.
Наконец, техническая целостность не исправляет непонятный интерфейс. Пользователь должен видеть цель, область, аргументы, ожидаемый эффект и последствия отказа — без скрытых шагов и расплывчатых формулировок.
Контрольный список
- Предложение сохраняется как неизменяемый серверный снимок.
- Подтверждение связано с хешем, субъектом, планом и версиями ресурсов.
- У подтверждения есть срок действия и явные конечные состояния.
- Права и динамические политики проверяются повторно перед эффектом.
- Запись выполняется через CAS, условный запрос или подходящую транзакцию.
- Устаревание всегда приводит к новому показу и новому подтверждению.
- Повторная доставка использует тот же ключ идемпотентности.
- Аудит хранит хеш снимка, решение, проверенные версии и результат попытки.
Главное правило: подтверждение относится к конкретной ревизии действия, а не к намерению агента вообще. Если изменилось что-либо, способное повлиять на смысл или последствия операции, старое согласие больше не применимо.