# Должны ли ожидающие запросы закрытого хранилища вообще ждать?

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

Это звучит жёстко, пока не задумаешься, что именно доказывает разблокировка. Она подтверждает, что человек разрешил доступ к хранилищу секретов в данный момент. Но она не доказывает, что старый процесс агента всё ещё выполняет ту же задачу, что развёртывание по-прежнему нужно, что pull request не изменился или что старые HTTP-тело и SSH-команда всё ещё имеют смысл.

Это одно из тех решений, которые маскируются под удобную функцию. Кто-то видит заблокированный запуск и предлагает очередь: удерживать запрос, показывать уведомление и выпускать работу после Touch ID. Очередь кажется полезной, потому что запрос уже сформирован. Именно поэтому она опасна. Сформированный запрос пересёк границу между планом и действием, ожидающим полномочий.

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

## Закрытое хранилище должно отклонять действие сразу

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

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

Результат должен объяснять агенту произошедшее, но не подталкивать его к слепому повтору. Например:

```json
{
  "ok": false,
  "error": {
    "code": "VAULT_LOCKED",
    "message": "The credential vault is locked. This action was not queued or sent.",
    "request_id": "req_7d4c1f",
    "retryable": false
  }
}
```

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

Не заменяйте этот результат кодом `503 Service Unavailable`, тайм-аутом или общей транспортной ошибкой. Такие ответы подсказывают добросовестному клиенту повторить в точности то же действие. Шлюзу нужен смысловой результат: «Управляемое человеком условие безопасности заблокировало этот вызов, и именно этот вызов завершён».

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

## Разблокировка не одобряет прежнее намерение

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

Смешение этих решений создаёт незаметную ошибку авторизации. Представьте, что агент подготовил SSH-команду для перезапуска сервиса. Разработчик закрыл ноутбук, хранилище заблокировалось, а агент всё равно отправил вызов. Через сорок минут разработчик вернулся и разблокировал Mac, чтобы проверить другую проблему. Перезапуск сработал, потому что шлюз сохранил старую команду. В этот момент разработчик не одобрял перезапуск. Он открыл доступ к хранилищу для себя.

Та же проблема встречается в менее очевидных ситуациях:

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

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

Поэтому шлюзу нужно чётко разделять решения:

1. Блокировка хранилища определяет, может ли начаться действие с использованием секрета.
2. Авторизация сессии определяет, распознан ли этот процесс агента в рамках текущего запуска.
3. Подтверждение отдельного вызова определяет, можно ли сейчас использовать учётные данные, требующие индивидуального согласия.

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

## Уведомление - это не очередь запросов

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

**Уведомление** - это факт без возможности действия. Оно может сообщать, что процесс в определённый момент попытался использовать ссылку на названные учётные данные для определённого класса назначения. Это помогает человеку решить, нужно ли разблокировать хранилище и вернуться к задаче. Уведомление не может восстановить заголовки, тело запроса, SSH-команду или токен подтверждения.

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

Различие важно и при реализации. Допустимо такое уведомление о заблокированной работе:

```json
{
  "event": "action_denied",
  "reason": "vault_locked",
  "session_id": "ses_31b8",
  "channel": "ssh",
  "credential_label": "production-deploy",
  "destination": "deploy host",
  "occurred_at": "2026-07-22T21:14:05Z"
}
```

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

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

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

## Дайте агенту понятную машину состояний

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

Используйте машину состояний, в которой запрос получает конечный результат после отказа на границе хранилища:

```text
received
  |
  +-- vault locked --> denied_locked (terminal)
  |
  +-- vault unlocked --> session check
                           |
                           +-- not approved --> denied_session (terminal)
                           |
                           +-- approved --> per-call check
                                             |
                                             +-- approval declined --> denied_call (terminal)
                                             |
                                             +-- approved --> dispatched --> completed
```

Важна не сама диаграмма. Важно, что у `denied_locked` нет стрелки обратно в `dispatched`. Новый запрос может войти через `received`, но старый не может снова попасть ни в одну точку выполнения.

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

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

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

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

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

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

Избегайте карточек вроде «Разрешить агенту доступ к production». Такое предложение заставляет человека одобрить категорию, тогда как детали контролирует агент. Пользователь может согласиться разрешить процессу чтение одного endpoint, но не вызов административного endpoint на том же хосте.

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

```json
{
  "approval_id": "apr_8c62",
  "session_id": "ses_31b8",
  "process_identity": "signed-authority-and-process-instance",
  "channel": "http",
  "credential_label": "billing-api",
  "method": "POST",
  "destination": "api.example.internal/v1/invoices",
  "payload_digest": "sha256:...",
  "expires_at": "2026-07-22T21:16:00Z",
  "used": false
}
```

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

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

## Проверяйте разблокировку в неподходящий момент

Самый показательный тест не «завершается ли вызов ошибкой при закрытом хранилище?», а «что происходит, когда до разблокировки пользователя мир меняется?»

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

1. Запустите задачу агента, которая планирует отправить `POST /deploy` с `{\"revision\":\"a1b2c3\"}`.
2. Заблокируйте хранилище до отправки вызова агентом.
3. Убедитесь, что шлюз вернул `VAULT_LOCKED`, а тестовый сервис ничего не получил.
4. Пока хранилище закрыто, измените разрешённую ревизию на `d4e5f6`.
5. Разблокируйте хранилище по другой причине.
6. Ничего не делайте с агентом.

Правильный результат скучен: тестовый сервис по-прежнему ничего не получает. Если он получает развёртывание для `a1b2c3`, в шлюзе есть путь отложенного выполнения.

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

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

## Не позволяйте клиентам скрывать отказ

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

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

Цикл восстановления агента должен выглядеть примерно так:

```text
if result.error.code == "VAULT_LOCKED":
    record_blocked_task()
    ask the user to unlock when appropriate
    stop this action

if user later resumes the task:
    reread relevant state
    decide whether the action is still needed
    create a new request
```

Строка `decide whether the action is still needed` важна. Её нельзя заменять на `retry request`. Агент мог получить новые инструкции пользователя, изменить файлы, переключить ветку или узнать о сбое теста. Теперь ему может понадобиться другое действие или не понадобиться никакое.

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

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

## Журналы должны доказывать, что ничего не отправлено

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

Записывайте канал, который использовался для попытки, идентификатор запуска агента, ссылку на учётные данные, нормализованное назначение, результат и временные метки. Состояние отправки должно быть указано явно. Операторы должны отличать `denied_before_dispatch` от `dispatch_started`, `remote_rejected` и `completed`, не разгадывая смысл строк исключений.

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

Запрос к аудиту должен помогать отвечать на такие сообщения об инцидентах:

```text
21:14:05  session ses_31b8 attempted SSH action using production-deploy
21:14:05  vault gate denied action before dispatch
21:15:41  vault unlocked by local user action
21:16:09  no action dispatched from ses_31b8
```

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

Разделение Sallyport на журнал Sessions для запусков и журнал Activity для отдельных вызовов хорошо подходит для этой задачи: отказ из-за закрытого хранилища относится и к истории запуска, и к следу конкретного действия. Офлайн-проверка `sp audit verify` также позволяет проверить целостность этой истории, не открывая хранилище.

## Удобные очереди создают вторую систему авторизации

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

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

Сделайте конструкцию проще. Граница хранилища отклоняет все действия, пока оно закрыто. Для новой сессии процесса может потребоваться одобрение. Учётные данные с подтверждением каждого вызова требуют явного согласия каждый раз. Остальные удобства должны находиться на стороне агента как восстановление задачи, где агент обязан заново собрать план и показать человеку, что изменилось.

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

## Сделайте безопасный путь удобнее небезопасного

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

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

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

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