ПРАКТИЧЕСКИЙ КЕЙС · БЕЗОПАСНОСТЬ
Как мы добавили подтверждение перед отправкой сообщения
Подготовленный AI-агентом текст нельзя автоматически отправлять от имени владельца. Даже хороший черновик может содержать неверного адресата, неподходящий тон или фразу, которую человек не собирался публиковать. Поэтому мы отделили генерацию текста от реальной отправки и поставили между ними обязательный контур подтверждения.
Проблема была не в кнопке
Наивная реализация выглядит просто: агент готовит ответ, бот показывает две кнопки — «Отправить» и «Отменить». Но сами кнопки ещё не создают безопасную границу. Если процесс генерации имеет доступ к методу отправки, он может обойти интерфейс. Если после подтверждения разрешено заменить текст, владелец одобрит одно сообщение, а наружу уйдёт другое.
Надёжная схема должна обеспечивать четыре свойства:
- агент умеет создать черновик, но не располагает полномочиями для отправки;
- подтверждение привязано к точному адресату, тексту и вложениям;
- одно подтверждение можно использовать только один раз и до определённого срока;
- по журналу можно восстановить, кто, когда и какую версию сообщения одобрил.
Это разделение важнее формулировки системного промпта. Просьба «не отправляй без разрешения» полезна как правило поведения, но не заменяет технический запрет.
Конкретный случай
Владелец пишет агенту: «Подготовь ответ Анне: встречу переносим на пятницу после 15:00». Агент определяет адресата и создаёт текст, однако его работа заканчивается на состоянии pending_approval.
В Telegram появляется карточка:
Кому: Анна · chat_id 481…
Действие: отправить личное сообщение
Текст:
«Анна, добрый день! Предлагаю перенести встречу
на пятницу. Мне подойдёт время после 15:00».
Действительно до: 16:20
[Отправить] [Изменить] [Отменить]
Если владелец выбирает «Изменить», старая карточка аннулируется. Исправленный текст получает новый идентификатор и требует нового подтверждения. Кнопка «Отправить» не передаёт текст сообщения через Telegram: она передаёт только идентификатор уже сохранённого черновика и одноразовый код.
Архитектура: агент готовит, отдельный исполнитель отправляет
Мы разделили операцию на два независимых компонента. Первый использует вызов инструментов только для создания черновика. Второй — отправитель — имеет доступ к учётным данным внешнего канала, но принимает только подтверждённые записи из хранилища.
Запрос владельца
↓
AI-агент → create_draft()
↓
pending_approval
↓
карточка владельцу
↓
approve() → approved
↓
отдельный worker → send_message()
↓
sent
У процесса агента не должно быть токена пользовательского аккаунта, SMTP-пароля или другого секрета, позволяющего отправить сообщение напрямую. Если Telegram-бот одновременно показывает карточки и доставляет сообщения, ограничение всё равно нужно сохранить в коде: модель вызывает только create_draft, а send_message недоступен в наборе её инструментов.
Шаг 1. Опишите состояния операции
Не храните подтверждение одним полем approved: true. Для разборов и повторных попыток нужна явная машина состояний:
draft
pending_approval
approved
sending
sent
rejected
expired
failed
Разрешите только ожидаемые переходы. Например, pending_approval → approved допустим, а rejected → approved — нет. После редактирования создаётся новая версия в состоянии pending_approval.
Минимальная таблица может выглядеть так:
CREATE TABLE outbound_messages (
id UUID PRIMARY KEY,
owner_id BIGINT NOT NULL,
recipient_id TEXT NOT NULL,
channel TEXT NOT NULL,
body TEXT NOT NULL,
payload_hash TEXT NOT NULL,
status TEXT NOT NULL,
approval_nonce TEXT UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
approved_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
provider_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Шаг 2. Зафиксируйте содержимое подтверждения
При создании карточки вычислите хеш из всех значимых полей операции:
payload_hash = SHA256(
channel
+ recipient_id
+ body
+ attachment_ids
+ reply_to_id
)
Перед отправкой исполнитель вычисляет хеш повторно. Несовпадение означает, что данные изменились после показа владельцу. Такая операция переводится в failed или обратно в pending_approval, но не отправляется.
В хеш нужно включить не только текст. Замена адресата или вложения может быть опаснее изменения одного предложения. Для группового сообщения также фиксируйте тему, ветку и режим форматирования.
Шаг 3. Привяжите кнопку к владельцу и сроку
Обработчик кнопки должен проверить:
- Telegram ID нажавшего совпадает с
owner_id; - операция находится в состоянии
pending_approval; - одноразовый
approval_nonceсовпадает; expires_atещё не наступил;- хеш сохранённой операции не изменился.
Не помещайте адресата и текст прямо в вебхук кнопки. Клиентские данные можно повторить или подменить. Callback должен содержать короткую ссылку на серверную запись, например approve:7f3a…:k92d….
Срок действия выбирается по процессу. Важен сам принцип: старую карточку нельзя подтвердить через несколько дней, когда контекст уже изменился. После истечения срока обработчик атомарно переводит запись в expired.
Шаг 4. Сделайте подтверждение одноразовым
Двойной клик, повторная доставка события или перезапуск обработчика не должны создавать вторую отправку. Здесь нужна идемпотентность: одна операция сохраняет один результат независимо от количества одинаковых запросов.
Переход в approved выполняйте условным обновлением:
UPDATE outbound_messages
SET status = 'approved',
approved_at = now(),
approval_nonce = NULL
WHERE id = :id
AND owner_id = :owner_id
AND status = 'pending_approval'
AND approval_nonce = :nonce
AND expires_at > now();
Если обновлена не одна строка, отправка не запускается. Это защищает от повторного нажатия и гонки между двумя обработчиками лучше, чем последовательность «прочитать статус, затем изменить».
Шаг 5. Ограничьте самого отправителя
Отправитель работает как отдельный worker и выбирает только записи со статусом approved. Даже после подтверждения он проверяет канал, адресата и допустимый размер сообщения.
Для адресатов и типов действий полезен список разрешённых. Например, первая версия системы может отправлять только личные текстовые сообщения известным контактам, но не писать в группы, не прикладывать файлы и не пересылать чужие сообщения.
{
"allowed_actions": ["send_text"],
"allowed_channels": ["telegram_private"],
"max_text_length": 3000,
"attachments_enabled": false,
"approval_required": true
}
Лимиты конфигурации — не универсальные значения. Их нужно подобрать под свой канал и задачу. Главное, чтобы расширение возможностей происходило явно, а не появлялось случайно вместе с новым инструментом.
Как проверить контур до рабочего запуска
Проверка должна выполняться в тестовом чате или через подменённый транспорт, который записывает запрос вместо реальной доставки. Пройдите сценарии по одному и сохраните ожидаемый результат:
- создание текста формирует черновик, но не вызывает метод отправки;
- подтверждение владельцем переводит ровно одну запись в
approved; - нажатие другим Telegram-пользователем получает отказ;
- двойное нажатие создаёт не более одной команды на отправку;
- просроченная карточка переходит в
expired; - изменение текста, адресата или вложения после показа блокирует операцию;
- редактирование создаёт новую версию и новое подтверждение;
- перезапуск worker не повторяет уже завершённую отправку.
Для последнего пункта недостаточно увидеть один ответ в интерфейсе. Проверьте сохранённый статус, идентификатор операции и число обращений к тестовому транспорту. Такая наблюдаемость позволяет отличить ошибку интерфейса от реального повторного действия.
Что обычно ломается
Агент всё ещё видит инструмент отправки
Тогда подтверждение остаётся договорённостью, а не границей доступа. Уберите метод из доступного агенту набора и перенесите секрет отправителя в отдельный процесс.
После подтверждения можно изменить черновик
Подтверждение относится к конкретной версии. Любое содержательное изменение должно аннулировать его и запрашивать новое.
Повторная попытка отправляет сообщение второй раз
Состояние sent нужно сохранять вместе с идентификатором внешнего сервиса. Если канал поддерживает ключ идемпотентности, передавайте стабильный идентификатор операции. Если результат сетевого запроса неизвестен, не повторяйте его вслепую: сначала проверьте состояние у провайдера или отправьте задачу на ручной разбор.
Карточка скрывает важные параметры
Владелец должен видеть реального адресата, полный текст, вложения, канал и срок действия. Подтверждение формулировки «выполнить действие» не является осознанным разрешением.
Журнал содержит только успешный статус
Сохраняйте создание черновика, показ карточки, решение владельца, переход к отправке и ответ канала. При этом не записывайте токены доступа и другие секреты.
Ограничения подхода
Approval-контур не гарантирует, что владелец внимательно прочитает текст. Он также не определяет, верны ли факты внутри сообщения. Для чувствительных сценариев нужны дополнительные проверки: выделение сумм и дат, предупреждение о новом адресате, отдельное подтверждение вложений или правило двух участников.
Контур не спасёт и при полном захвате аккаунта владельца: злоумышленник сможет нажать настоящую кнопку. Для критических действий подтверждение стоит переносить во второй канал или защищать дополнительной аутентификацией.
Наконец, не каждое исходящее сообщение требует одинакового контроля. Ответ самому владельцу можно формировать автоматически. Черновик для внешнего адресата требует подтверждения. Массовая рассылка, платёж или публикация нуждаются в более строгом процессе. Уровень защиты должен зависеть от последствий действия.
Итоговая граница ответственности
Мы считаем безопасной не систему, которая показывает кнопку, а систему, в которой агент технически не способен отправить неподтверждённый текст. Он готовит предложение. Владелец видит точное действие и принимает решение. Отдельный исполнитель проверяет неизменность данных, срок и одноразовость разрешения — и только затем обращается к внешнему сервису.