# Безопасные API для AI-агентов: отслеживаемые записи

AI-агент сам по себе не делает API опасным. API становится опасным, когда предлагает слишком широкие команды, неоднозначные результаты и ошибки, из-за которых вызывающей стороне приходится гадать. Человек компенсирует это контекстом, осторожностью и быстрым сообщением в чате. Агент компенсирует неопределенность повторами и новым вызовом инструмента. Из-за этой разницы безобидный тайм-аут превращается в два возврата денег, два развертывания или удаленную запись, которую никто не собирался трогать.

Безопасные API для AI-агентов ограничивают разрешенное действие, делают повторные записи безвредными и оставляют след, по которому человек сможет разобраться позже. Это задача проектирования API, а не промпта. В подсказке можно попросить агента быть осторожнее, но конечная точка все равно должна отклонять действие, которое выходит за пределы ее контракта.

Я видел, как команды ставили экран подтверждения перед расплывчатой административной конечной точкой и считали это защитой. Этого недостаточно. Если одобренный вызов означает «изменить что угодно в этой учетной записи», человеку предлагают под давлением проверить набор скрытых последствий. Сначала задайте точность на уровне API. Тогда подтверждать можно будет понятное действие.

## Широкие команды заставляют агентов гадать

Агент должен вызывать операцию, название, входные данные и побочные эффекты которой можно описать одним предложением. Широкие конечные точки заставляют его выводить бизнес-правила из расплывчатых полей, старых примеров или ошибки, в которой сказано лишь «неверный запрос». Так начинается опасная импровизация.

Представьте конечную точку вроде `POST /admin/execute` с телом, содержащим `action` и произвольный JSON. Клиент, написанный человеком, сегодня может использовать всего пять действий, но конечная точка открывает любому получившему доступ вызывающему все текущие и будущие действия. Сервер не может обозначить полезную границу разрешений, а подтверждающий не поймет, что сделает агент, не прочитав тело запроса как исходный код.

Замените ее операциями, которые называют переход состояния:

- `POST /projects/{project_id}/deployments` создает одно развертывание из указанной ревизии.
- `POST /invoices/{invoice_id}/refunds` создает возврат с явной суммой и причиной.
- `POST /users/{user_id}/access-revocations` отзывает доступ у одного указанного пользователя.
- `POST /exports` запускает определенный экспорт с объявленной категорией данных.

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

Не путайте универсальный CRUD-интерфейс с удобным интерфейсом для агента. `PATCH /customers/{id}` предлагает изменить любое доступное для записи поле. Если изменение `billing_email` обычно, а изменение `tax_status` запускает процесс проверки соответствия требованиям, эти изменения не должны скрываться за одним небрежным patch-запросом. Создайте отдельную операцию для значимого перехода и сформируйте ее входные данные вокруг принимаемого решения.

Узкая операция улучшает и восстановление. Когда агент говорит: «Запрос на развертывание завершился тайм-аутом», оператор может найти создание одного развертывания. Когда агент говорит: «Административная команда завершилась тайм-аутом», оператору сначала нужно выяснить, какую команду тот собрал.

### Добавляйте предусловия в запрос

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

HTTP уже дает полезные механизмы. RFC 9110 описывает условные запросы с заголовками вроде `If-Match`; сервер может отклонить устаревший тег сущности с ответом `412 Precondition Failed`. При необходимости можно использовать поле `expected_version`. Важен не выбор механизма, а правило: клиент должен назвать версию или состояние, которое собирается изменить.

Не принимайте поле клиента вроде `force: true` как универсальный обход любого конфликта. Такое поле быстро превращается в способ обойти именно ту проверку безопасности, которую вы добавили. Оставьте принудительное действие для отдельной операции, другого уровня авторизации и заметной записи в аудите.

## У записи должен быть отдельный от HTTP-попытки идентификатор

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

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

Обычная последовательность выглядит так:

1. Агент отправляет запрос на создание выплаты.
2. Сервер сохраняет выплату и вызывает внешнего поставщика.
3. Соединение разрывается до того, как агент получает успешный ответ.
4. Агент видит неизвестный результат и повторяет запрос.
5. Сервер создает еще одну выплату, потому что видит новый HTTP-запрос.

Причиной ошибки стала не политика повторов. Ошибка возникла потому, что API принял доставку за намерение.

Используйте заголовок или поле запроса, которое клиент создает до первой попытки и сохраняет до получения окончательного ответа. Имена HTTP-заголовков обычно используют `Idempotency-Key`, хотя сам идентификатор не обязан быть секретом. Хорошо подходит случайный UUID. Не создавайте его только из временной метки и не используйте значение, которое может совпасть у несвязанных записей.

### Сохраняйте отпечаток запроса и результат

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

Проект IETF «The Idempotency-Key HTTP Header Field» описывает этот заголовок как способ сделать неидемпотентные методы HTTP устойчивыми к сбоям. Важное предупреждение касается уникальности: клиент не должен использовать одно значение для другого запроса. На практике я бы пошел дальше и обеспечил это правило на сервере, потому что агенты повторяют запросы, перезапускаются и иногда повторно используют состояние, которое человек-клиент уже отбросил.

Компактный контракт может выглядеть так:

```http
POST /v1/projects/prj_48/deployments
Idempotency-Key: 8c8d77c1-4ef9-4fae-b0ba-5480f686ce4c
Content-Type: application/json

{
  "revision": "a1b2c3d4",
  "environment": "staging",
  "expected_project_version": 17
}
```

При первом принятом вызове верните ресурс и оба идентификатора:

```json
{
  "request_id": "req_01J8X7QK3JZ6",
  "deployment": {
    "id": "dep_01J8X7R5G2",
    "state": "queued",
    "revision": "a1b2c3d4",
    "environment": "staging"
  }
}
```

Если после тайм-аута агент повторит тот же запрос, верните тот же `dep_01J8X7R5G2`, а не второе развертывание. Если он изменит `environment` на `production`, сохранив идентификатор, верните конфликт с понятным способом исправления:

```json
{
  "error": {
    "code": "idempotency_payload_mismatch",
    "message": "This idempotency identifier belongs to a deployment request with different parameters.",
    "request_id": "req_01J8X84S9P2V"
  }
}
```

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

## Повтор имеет смысл только при достаточно понятном результате

Агент должен повторять транспортные сбои и отдельные временные ответы, но не должен придумывать новое действие, чтобы выйти из неопределенности. Классы ответов должны позволять принять такое решение.

RFC 9110 определяет `429 Too Many Requests` и допускает `Retry-After`; если вы отправляете этот заголовок, соблюдайте его. Вызывающая сторона может подождать указанное время, сохранить идентификатор идемпотентности и отправить тот же запрос. При временной ошибке сервера верните ответ 5xx с идентификатором запроса и укажите, принял ли сервер операцию. Не используйте расплывчатый 500 для ошибки проверки или отказа в авторизации. Это обучает клиентов неправильной стратегии повторов.

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

```json
{
  "request_id": "req_01J8X9FW7GH2",
  "operation": {
    "id": "op_01J8X9FTVX",
    "state": "running",
    "status_url": "/v1/operations/op_01J8X9FTVX"
  }
}
```

Ресурсу состояния недостаточно значений `running` и `failed`. Добавьте конечное состояние, ссылку на результат при успехе и публичный код ошибки, если рабочий процесс не может завершить задачу. Например, развертывание, не прошедшее проверки работоспособности, не должно выглядеть как ошибка транспорта API. Агенту нужно сообщить о сбое развертывания или исправить его; повторять следует только ошибку соединения, если сервер так и не принял запрос.

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

## Ошибки должны объяснять, как исправить запрос

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

Возвращайте единый JSON-конверт для каждой ожидаемой ошибки. Добавьте стабильный `code` для программ, короткое `message` для логов и людей, идентификатор запроса и подробности по полям, если их безопасно раскрывать. RFC 9457, «Problem Details for HTTP APIs», предлагает стандартную форму с полями `type`, `title`, `status`, `detail` и `instance`. Необязательно принимать все поля, чтобы усвоить главное: ошибки входят в контракт API, а не являются случайным текстом.

Такой ответ точно говорит агенту, что изменить:

```json
{
  "error": {
    "code": "invalid_state_transition",
    "message": "A refund can be created only for a paid invoice.",
    "request_id": "req_01J8XAS2D8M4",
    "details": {
      "invoice_id": "inv_204",
      "current_state": "draft",
      "allowed_states": ["paid", "partially_paid"]
    }
  }
}
```

Такой ответ заставляет гадать:

```json
{
  "error": "Request failed"
}
```

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

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

Отделяйте неверный ввод от недостаточных полномочий. `422 Unprocessable Content` может описывать правильно сформированный запрос, нарушающий бизнес-правило. `403 Forbidden` должен сообщать, что операция требует разрешения или подтверждения, не раскрывая ресурсы, которые вызывающая сторона не может просматривать. `404 Not Found` подходит, если вы намеренно скрываете существование ресурса. Выберите семантику, задокументируйте ее и применяйте последовательно.

Хорошая ошибка также говорит, когда повтор бесполезен. `invalid_state_transition`, `idempotency_payload_mismatch` и `approval_required` должны останавливать слепые повторы. `rate_limited` с задержкой повтора и `upstream_temporarily_unavailable` могут разрешать контролируемый повтор. Это различие предотвращает больше ущерба, чем хитрая подсказка для агента.

## Идентификаторы запросов превращают спорное действие в расследование

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

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

Записывайте структурированные события, а не собирайте строки прозы, которые операторам потом придется разбирать регулярными выражениями. Как минимум фиксируйте идентификатор запроса, аутентифицированного субъекта, название операции, целевой ресурс, идентификатор идемпотентности при наличии, решение авторизации, статус результата и ссылки на созданные ресурсы. Скрывайте поля по схеме, а не фильтром строк в последний момент. Поле с именем `token` легко скрыть. Учетные данные внутри произвольного текста скрыть гораздо сложнее.

Трассировка должна сохранять порядок событий, но не утверждать больше, чем она доказывает. Идентификатор запроса может показать, что API принял задачу, а рабочий процесс отправил вызов поставщику. Он не доказывает, что действие намеренно выбрал человек, если система не записывает такое решение отдельно. Разделяйте понятия:

- Запись корреляции связывает события одного запроса.
- Аудит-запись сообщает, кто или что разрешил действие и что сделала система.
- Запись идемпотентности предотвращает повторную логическую запись.

Команды часто объединяют все это в одной строке базы данных. Тогда строка должна одновременно обслуживать повторы, отладку, проверку соответствия требованиям и историю для пользователя, но ни с одной задачей не справляется хорошо. Можно хранить связанные ссылки вместе, однако разные смыслы должны оставаться разными в модели данных.

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

Sallyport использует полезный подход к действиям агентов: он записывает сессии агентов и отдельные вызовы в один зашифрованный аудит-лог, недоступный для записи, с хеш-цепочкой, а `sp audit verify` проверяет цепочку автономно, без ключа хранилища. Вашему API все равно нужны собственные записи, потому что шлюз может показать, что отправил вызов, но только ваш сервис может показать зафиксированный им переход состояния.

## Область действия аутентификации не исправляет опасную операцию

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

Связывайте авторизацию с операцией и целью. Вызывающая сторона, которой разрешено создать развертывание, не должна автоматически получать право продвинуть его в production. Тот, кто может отозвать доступ пользователя, не должен получать разрешение менять его платежный профиль только потому, что оба действия находятся под `/users/{id}`.

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

Это не отменяет проверку параметров. Если агент может указать `url: https://anything.example`, HTTP-помощник, добавляющий учетные данные, превращается в инструмент кражи секретов. Привязывайте учетные данные к именованным внешним системам и методам. Проверяйте хосты и после перенаправлений, а не только до них. Для SSH по возможности связывайте учетные данные с известными хостами и ограниченным интерфейсом команд вместо произвольного удаленного shell-доступа.

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

## При параллельной работе должен быть явный проигравший

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

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

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

Не говорите агентам «сначала проверь, потом действуй» и не считайте это единственным контролем параллельности. Предварительный `GET` помогает агенту сформировать полезный запрос, но другой вызывающий может изменить состояние между чтением и записью. Корректность принадлежит конечной точке записи, потому что именно она видит фактическое состояние в момент фиксации.

Так же тщательно проектируйте отмену. `DELETE /operations/{id}` не должен обещать, что внешнее действие никогда не произошло. Он должен возвращать фактическое состояние отмены: отмена запрошена, отменено до отправки, действие завершилось до отмены или отмена невозможна после отправки. И агентам, и людям нужны формулировки, отражающие границу между вашей системой и внешним поставщиком.

## Проверяйте неизвестные результаты до того, как агенты найдут их в production

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

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

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

Проверяйте контракт ошибок как данные. Утверждайте HTTP-статусы, стабильные коды ошибок, имена полей и наличие идентификатора запроса. Не создавайте снимок только английского сообщения. Формулировки со временем улучшатся, а клиенты должны ветвиться по `code`, а не по тексту.

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

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