Облачный агент рано или поздно упирается в чужую систему: реестр образов, базу для миграции, объектное хранилище, внутренний сервис компании. Привычный ответ - положить ключ доступа в переменные окружения запуска и считать вопрос закрытым. Cursor предлагает другой порядок. Внутри рабочей машины слушает локальный сокет, у которого код может попросить короткоживущий подписанный токен личности, и уже этот токен меняется на временные учётные данные облачного провайдера. Долгоживущего секрета в машине нет, потому что его туда не клали.
Хранение секрета в окружении выглядит разумным не по недомыслию. Так работают сборочные системы двадцать лет подряд: положили ключ в защищённую переменную, прочитали её в шаге сборки, получили доступ. Модель понятная, отлаживается за минуту, переносится между инструментами без переделки и не требует ничего от той стороны, к которой обращаются. Пока запуск инициирует человек и смотрит на его результат, слабые места этой модели остаются теоретическими.
Автономная работа делает их практическими. Ключ в окружении - долгоживущее значение, которое одинаково действует вчера, сегодня и через полгода. Он копируется: всё, что однажды прочитало переменную, унесло с собой полноценный доступ, и отозвать его можно только вместе с самим ключом. Он ни к чему не привязан: журнал провайдера покажет, что доступ был, но не скажет, какой запуск, в каком репозитории и по чьему заданию его выполнил. И ротируется он руками, то есть в реальности не ротируется, пока не случится инцидент.
Механизм устроен просто. Адрес локального сокета лежит в переменной CURSOR_AGENT_SOCKET и по умолчанию равен /run/cursor/api.sock. Код отправляет туда POST на /v1/tokens/oidc и обязательно называет аудиторию - имя той системы, для которой токен предназначен, до 512 печатных символов. В ответ приходит подписанный по RS256 веб-токен и метка истечения в секундах эпохи. Проверяющая сторона при этом не хранит ничего: она берёт открытые ключи по адресу api.cursor.com/keys, находит нужный по идентификатору ключа из заголовка, сверяет подпись, издателя api.cursor.com, свою аудиторию и окно действия с поправкой на расхождение часов. Все адреса собраны в документе обнаружения по стандартному пути .well-known/openid-configuration.
Ценность механизма не в самой подписи, а в составе утверждений. В каждом токене всегда есть издатель, субъект владельца в виде user с идентификатором или service_account с идентификатором, запрошенная аудитория, три отметки времени, уникальный идентификатор токена, идентификатор облачного агента и признак среды исполнения. По обстоятельствам добавляются почта и идентификатор владельца, идентификатор команды, идентификатор текущего прогона и время его начала, адрес репозитория или список адресов с их числом, имя ветки, идентификатор окружения, источник запуска и идентификатор автоматизации. Правило доверия на стороне провайдера пишется поэтому не как доступ для того, кто предъявил ключ, а как доступ для агента такой-то команды, работающего в таком-то репозитории.
Две необязательные детали запроса закрывают практические углы. Значение nonce привязывает токен к конкретному обращению и мешает переиграть перехваченный ответ. Параметр sub_claim проецирует выбранное утверждение прямо в субъект в виде имя:значение, и сейчас поддерживается идентификатор команды. Нужно это потому, что многие проверяющие умеют сопоставлять по субъекту и не умеют по произвольным полям: без такой проекции политику доверия пришлось бы писать грубее, чем хотелось бы.
Дальше токен меняют на временные учётные данные. В AWS для этого заводят поставщика удостоверений с издателем api.cursor.com, ставят аудиторию sts.amazonaws.com, описывают в политике доверия совпадение по субъекту и, если нужно, по идентификатору команды, и вызывают обмен по веб-удостоверению. GCP делает то же через федерацию рабочих нагрузок, Azure - через федеративные учётные данные, Vault - через вход по JWT, а собственный сервис - через любой стандартный проверяющий, потому что ничего нестандартного в токене нет. Ниже такая последовательность целиком:
Цена механизма измерима. Токен живёт пять минут, обновления не предусмотрено: истёк - выпускайте новый, а значит долгие операции надо либо укладывать в это окно, либо строить на временных учётных данных провайдера, которые живут дольше. Частота ограничена: тридцать токенов в минуту на машину со всплеском до десяти, не больше восьми одновременных соединений с сокетом, тело запроса до четырёх килобайт. Коды 429, 503, 500, 502 и 504 стоит повторять с отступом, а 403 повторять бессмысленно - он означает, что этой машине выпуск не разрешён. И отдельная строка расходов - настройка доверия на стороне каждого провайдера, которую делают руками один раз и потом сопровождают.
Границу доверия надо понимать буквально: токен удостоверяет прогон целиком, а не отдельный процесс внутри него. Любой код, дотянувшийся до сокета, получит токен от имени агента, поэтому права роли раздают по минимуму задачи, а не по удобству; со стороны хоста закрыто другое - гость не может подделать личность или выпустить токен за другого агента. Проверяют результат не по факту успешного вызова: на первом боевом прогоне полезную нагрузку разбирают и глазами смотрят на субъект, аудиторию, идентификатор агента и время истечения; в журнале провайдера убеждаются, что сессия связана именно с этим прогоном; и отдельно проверяют отказ - просят у роли то, чего ей не положено, и убеждаются, что провайдер отказал.
Типичные провалы повторяются. Оставить рядом с новым механизмом старый долгоживущий ключ на случай, если не заработает, и забыть его убрать. Описать политику доверия только по издателю и аудитории, не сузив по субъекту, - тогда роль доступна любому агенту, а не вашему. Выпускать токен на каждый запрос в цикле и упереться в частоту вместо того, чтобы переиспользовать уже полученные временные учётные данные. Считать, что подпись под личностью означает доверие к содержимому работы агента. И раздать роли широкие права, полагаясь на то, что до локального сокета никто лишний не дотянется.
# адрес сокета лежит в переменной окружения, по умолчанию /run/cursor/api.sock
SOCKET="${CURSOR_AGENT_SOCKET:-/run/cursor/api.sock}"
# токен выпускается для одной аудитории и живёт пять минут, обновления нет
TOKEN=$(curl -sS --unix-socket "$SOCKET" \
-X POST http://localhost/v1/tokens/oidc \
-H 'Content-Type: application/json' \
-d '{"aud":"sts.amazonaws.com"}' | jq -r .token)
# обмен подписанного токена на временные учётные данные роли
aws sts assume-role-with-web-identity \
--role-arn "$ROLE_ARN" \
--role-session-name cursor-cloud-agent \
--web-identity-token "$TOKEN"