Практическое руководство

Контур подтверждения для опасных действий AI-агента

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

Уровень: средний Чтение: до 8 минут Результат: очередь подтверждений и журнал решений

Зачем нужен отдельный контур

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

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

Минимальная модель

Разделим систему на четыре компонента:

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

Журнал решений отделён от текущего состояния заявки. Смена статуса удобна для работы очереди, но сама по себе не объясняет, кто и когда принял решение. Поэтому каждое событие записывается дополнительно и не редактируется задним числом.

Класс действия Что показывать проверяющему Дополнительное условие
Удаление Тип и идентификатор объекта, область удаления Версия объекта или контрольная сумма
Оплата Получатель, сумма, валюта, назначение Лимит и идемпотентный ключ
Публикация Канал, аудитория, финальный текст или его снимок Запрет незаметного изменения после одобрения

Воспроизводимая реализация

Шаг 1. Введите политику действий

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

actions:
  read_document:
    approval: never

  delete_record:
    approval: required
    expires_in_seconds: 900

  create_payment:
    approval: required
    expires_in_seconds: 300
    max_amount_minor: 100000

  publish_post:
    approval: required
    expires_in_seconds: 1800

default:
  approval: required

Безопасное значение по умолчанию — required. Новое действие тогда не станет автономным только потому, что разработчик забыл добавить его в список.

Шаг 2. Опишите заявку как снимок намерения

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

{
  "approval_id": "appr_example_001",
  "action": "publish_post",
  "resource": {
    "channel_id": "channel_example",
    "content": "Пример текста публикации"
  },
  "risk": "public_write",
  "requested_by": "agent_example",
  "created_at": "2030-01-01T10:00:00Z",
  "expires_at": "2030-01-01T10:30:00Z",
  "status": "pending",
  "payload_hash": "sha256:EXAMPLE_NOT_A_REAL_DIGEST",
  "idempotency_key": "idem_example_001"
}

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

Шаг 3. Создайте таблицы очереди и журнала

Ниже приведён минимальный пример для реляционной базы данных. Типы и функции времени при необходимости адаптируйте к выбранной СУБД.

CREATE TABLE approval_requests (
  approval_id       VARCHAR(64) PRIMARY KEY,
  action_name       VARCHAR(80) NOT NULL,
  payload_json      TEXT NOT NULL,
  payload_hash      VARCHAR(80) NOT NULL,
  status            VARCHAR(16) NOT NULL
                    CHECK (status IN ('pending', 'approved', 'rejected',
                                      'expired', 'executed', 'failed')),
  requested_by      VARCHAR(128) NOT NULL,
  created_at        TIMESTAMP NOT NULL,
  expires_at        TIMESTAMP NOT NULL,
  idempotency_key   VARCHAR(128) NOT NULL UNIQUE,
  decided_by        VARCHAR(128),
  decided_at        TIMESTAMP,
  executed_at       TIMESTAMP
);

CREATE TABLE approval_events (
  event_id          VARCHAR(64) PRIMARY KEY,
  approval_id       VARCHAR(64) NOT NULL,
  event_type        VARCHAR(32) NOT NULL,
  actor_id          VARCHAR(128) NOT NULL,
  occurred_at       TIMESTAMP NOT NULL,
  payload_hash      VARCHAR(80) NOT NULL,
  reason            VARCHAR(500),
  FOREIGN KEY (approval_id)
    REFERENCES approval_requests(approval_id)
);

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

Шаг 4. Разорвите прямой путь к опасному API

Инструмент агента не должен называться и работать как delete_now или pay_now. Для опасных операций предоставьте ему только создание заявки:

def request_action(actor, action, payload):
    policy = load_policy(action)

    if policy.approval != "required":
        return execute_safe_action(actor, action, payload)

    normalized = normalize_and_validate(action, payload)
    request = create_pending_request(
        actor=actor,
        action=action,
        payload=normalized,
        expires_in=policy.expires_in_seconds,
    )
    append_event(request, "requested", actor, reason=None)
    return {
        "status": "approval_required",
        "approval_id": request.approval_id
    }

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

Шаг 5. Принимайте решение атомарно

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

BEGIN;

UPDATE approval_requests
SET status = 'approved',
    decided_by = :reviewer_id,
    decided_at = CURRENT_TIMESTAMP
WHERE approval_id = :approval_id
  AND payload_hash = :visible_payload_hash
  AND status = 'pending'
  AND expires_at > CURRENT_TIMESTAMP;

-- Продолжать только если изменена ровно одна строка.

INSERT INTO approval_events (
  event_id, approval_id, event_type, actor_id,
  occurred_at, payload_hash, reason
) VALUES (
  :event_id, :approval_id, 'approved', :reviewer_id,
  CURRENT_TIMESTAMP, :visible_payload_hash, :reason
);

COMMIT;

Отклонение оформляется таким же переходом из pending в rejected. Причина полезна для разбора, но не должна содержать секреты.

Шаг 6. Повторно проверьте заявку перед выполнением

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

def execute_approved(approval_id):
    request = load_for_update(approval_id)

    require(request.status == "approved")
    require(now() < request.expires_at)
    require(hash_payload(request.payload) == request.payload_hash)
    require(reviewer_is_authorized(request.decided_by, request.action))
    require(current_policy_allows(request))

    claim_once(request.approval_id, request.idempotency_key)
    result = call_external_api(
        action=request.action,
        payload=request.payload,
        idempotency_key=request.idempotency_key,
    )
    mark_executed_and_append_event(request, result)
    return result

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

Проверка результата

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

  1. Создайте тестовую заявку на удаление фиктивной записи. Убедитесь, что до решения объект остаётся на месте.
  2. Отклоните заявку. Исполнитель должен отказаться от вызова, а журнал — содержать события requested и rejected.
  3. Создайте новую заявку и подтвердите её. Проверьте, что выполнился именно показанный набор параметров.
  4. Повторно отправьте команду выполнения. Второго внешнего эффекта быть не должно.
  5. Измените снимок после создания заявки. Сверка контрольной суммы должна остановить выполнение.
  6. Дождитесь истечения короткого тестового срока. Просроченная заявка должна перейти в expired или быть отклонена исполнителем.
  7. Попробуйте подтвердить заявку учётной записью без нужной роли. Решение не должно сохраниться.

Для ручной проверки очереди безопасен запрос только на чтение:

SELECT approval_id, action_name, status,
       requested_by, created_at, expires_at
FROM approval_requests
WHERE status = 'pending'
ORDER BY created_at ASC;

А этим запросом можно сопоставить решение и последующее выполнение:

SELECT approval_id, event_type, actor_id,
       occurred_at, payload_hash, reason
FROM approval_events
WHERE approval_id = :approval_id
ORDER BY occurred_at ASC;

Ожидаемый результат — непрерывная последовательность событий без выполнения до approved, с одинаковой контрольной суммой на всех этапах.

Типовые ошибки

Подтверждение хранится только в чате
Фраза «да, продолжай» неоднозначна и может относиться к другому плану. Решение должно ссылаться на конкретную заявку и её контрольную сумму.
Человеку показывают краткий пересказ
Формулировка «опубликовать отчёт» скрывает канал, аудиторию и содержание. Подтверждать нужно фактический снимок операции.
Агент и исполнитель используют одинаковые полномочия
Тогда очередь можно обойти прямым вызовом. Разделите роли и учётные данные на уровне инфраструктуры.
Разрешение действует бессрочно
Контекст успевает измениться: цена, версия документа или владелец ресурса уже другие. Добавьте короткий срок и повторную проверку.
Кнопку можно нажать дважды
Двойная доставка, повтор HTTP-запроса и перезапуск процесса нормальны для распределённых систем. Используйте атомарный переход статуса и ключ идемпотентности.
В журнал попадают секреты
Аудит не оправдывает хранение токенов или платёжных реквизитов без необходимости. Записывайте идентификаторы, хеши и минимальные безопасные атрибуты.
Ошибки автоматически считаются разрешением
Недоступная база, повреждённая политика или невозможность проверить роль должны останавливать действие.

Ограничения

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

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

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

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

Критерии готовности

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

После выполнения этих условий агент может готовить опасные операции, но граница окончательного решения остаётся контролируемой и проверяемой.