# Доступ ИИ-агентов по SSH: удалённые команды без закрытых ключей

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

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

Я видел, как команды по несколько дней ужесточали правила для агента, а затем оставляли `~/.ssh/id_ed25519` доступным тому же процессу. После этого правила для запросов превращаются в декорацию. Команда оболочки может скопировать файл, вывести его, упаковать в архив или отправить туда, где вы обнаружите это лишь через несколько недель.

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

## Давайте агенту действие, а не идентичность

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

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

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

Вторая задача тоже требует внимания. Просто теперь её можно нормально решать.

Полезный формат запроса намеренно скучен:

```json
{
  "host": "deploy-01.internal.example",
  "user": "release",
  "command": "/usr/local/libexec/release-service",
  "args": ["api", "2025.03.08-4f2c1a7"],
  "timeout_seconds": 120
}
```

Не передавайте через пять уровней строку оболочки вроде `ssh deploy-01 'cd /srv/api && git pull && sudo systemctl restart api'`, а затем не называйте это контролем. Правила разбора оболочки становятся частью границы безопасности, хотя почти никто не проверяет их с этой точки зрения. Зафиксируйте путь к команде, передавайте аргументы отдельными значениями, а удалённая обёртка должна отклонять всё, что не соответствует ожидаемой грамматике.

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

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

```json
{
  "exit_code": 0,
  "stdout": "released api version 2025.03.08-4f2c1a7\n",
  "stderr": "",
  "duration_ms": 1842
}
```

Не возвращайте учётные данные, сокеты агента, содержимое `known_hosts` или интерактивный TTY. Это детали реализации доверенной стороны.

## Владение, подпись и выполнение дают разные полномочия

Команды часто говорят «доступ по SSH», будто это что-то одно. На деле есть как минимум три существенно разных полномочия: владеть закрытым ключом, просить агента создать подпись и заставлять брокер выполнять названную удалённую команду.

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

`ssh-agent` избавляет каждый клиент от необходимости читать файл закрытого ключа. Процесс может запросить подпись через `SSH_AUTH_SOCK`. Это улучшает гигиену на локальной рабочей станции, но подпись всё равно даёт право аутентификации. В руководстве OpenSSH `ssh-agent(1)` сказано, что агент не передаёт закрытые ключи по сети при переадресации, а переадресованный запросчик получает результат операций с идентификаторами. Это полезное свойство, но не полноценная модель авторизации.

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

Последняя деталь важнее, чем кажется. Один идентификатор, который умеет читать журналы, развёртывать код, редактировать `/etc/sudoers` и открывать туннели, создаёт границу авторизации размером со весь парк серверов. Четыре идентификатора с узкими серверными ограничениями создают четыре небольшие задачи.

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

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

## Переадресация SSH-агента решает другую задачу

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

OpenSSH прямо говорит об этом в `ssh_config(5)`: переадресацию агента следует включать осторожно, потому что пользователь, способный обойти ограничения удалённого хоста, может использовать локальный агент через переадресованное соединение. Злоумышленник не может извлечь через этот интерфейс байты закрытого ключа, но может попросить загруженные идентификаторы пройти аутентификацию.

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

Не включайте `ForwardAgent yes` глобально. Добавьте `ForwardAgent no` в базовую конфигурацию клиента и явно разрешайте переадресацию только для конкретного человеческого сценария, которому она действительно нужна.

```sshconfig
Host *
    ForwardAgent no
    AddKeysToAgent no
    IdentitiesOnly yes
    StrictHostKeyChecking yes

Host legacy-bastion
    HostName bastion.internal.example
    User ops
    ForwardAgent yes
```

Даже этому исключению нужны причина, владелец и дата удаления. Постоянное исключение для переадресации легко становится незаметным, потому что SSH работает с ним слишком гладко.

Идентификаторы с ограничением по назначению улучшают ситуацию, но не заменяют границу действий. В руководстве OpenSSH `ssh-add(1)` объясняется, что такие ограничения проверяют весь путь соединения, когда взаимодействующие клиент и сервер переадресуют агент. Там же есть предупреждение: пользователь с удалённым `SSH_AUTH_SOCK` может переадресовать сокет ещё раз, хотя использование останется ограничено разрешёнными направлениями.

Используйте ограничения по назначению там, где они уместны. Не принимайте их за политику команд. Они определяют, где идентификатор может аутентифицироваться, но не отвечают на вопрос, должна ли после подключения выполняться `rm -rf /srv/release-cache`.

## Проследите отказ по всей цепочке команды

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

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

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

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

Теперь рассмотрим архитектуру с брокером. Агент отправляет запрос `release-service api 2025.03.08-4f2c1a7` на `deploy-01`. Брокер видит, что это первый запрос нового процесса агента, и просит разрешение. Одобряющий видит полномочия вызывающего процесса, удалённый идентификатор и цель. Брокер подключается только к названному хосту. Сервер принимает только ограниченный идентификатор развёртывания и запускает одну серверную обёртку.

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

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

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

## Поместите SSH-клиент за локальную точку принятия решений

Локальная точка принятия решений должна владеть тремя вещами: зашифрованным закрытым идентификатором, разрешением на его использование и свидетельствами, возникающими при этом. Агент не должен владеть ни одной из них.

Sallyport использует такой подход для агентов на macOS: встроенный модуль `sp mcp` принимает обычные вызовы MCP, а приложение в строке меню хранит SSH-идентификаторы в зашифрованном хранилище и использует `sp-ssh` для соединения. Агент получает результат команды, а не учётные данные.

Важнее форма, чем конкретная реализация:

```text
AI agent process
    -> local action request
        -> authorization decision
            -> SSH helper using protected identity
                -> remote sshd and restricted account
                    -> stdout, stderr, exit status
```

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

Идентичность процесса не должна быть декоративным полем в карточке одобрения. Запрос от ожидаемого подписанного приложения отличается от запроса неподписанного бинарного файла, запущенного из `/tmp`, даже если оба называют себя «агентом для программирования». В macOS полномочия подписи кода дают одобряющему конкретный объект для проверки.

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

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

## Сделайте удалённую учётную запись намеренно скучной

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

Создайте отдельную Unix-учётную запись для действия, например `release`, `diagnostics` или `backup`. Не давайте ей личный профиль оболочки, широкую домашнюю директорию или вход по паролю. Не помещайте её открытый идентификатор рядом с неограниченным личным идентификатором инженера в одном `authorized_keys`, а затем не называйте учётные записи раздельными.

Для операции выпуска запись в `authorized_keys` может выглядеть так:

```text
restrict,command="/usr/local/libexec/release-service" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyReplaceThis release-broker
```

Параметр `restrict` отключает проброс портов, агента и X11, выделение PTY и выполнение `~/.ssh/rc`. Руководство OpenSSH `sshd(8)` описывает `restrict` именно для этой цели и показывает его вместе с принудительной командой.

Принудительная команда не должна передавать исходную командную строку в `sh -c`. Игнорируйте `SSH_ORIGINAL_COMMAND`, если только у вас нет узкого парсера с тестами. Обёртка должна принимать фиксированный протокол, проверять каждое поле, записывать событие аудита и запускать настоящую операцию с явными границами аргументов.

Небольшая оболочная обёртка допустима, если она остаётся небольшой:

```sh
#!/bin/sh
set -eu

service=${1:-}
version=${2:-}

case "$service" in
  api|worker) ;;
  *) echo "unsupported service" >&2; exit 64 ;;
esac

case "$version" in
  *[!0-9A-Za-z._-]*|"") echo "invalid version" >&2; exit 64 ;;
esac

exec /usr/bin/sudo /usr/local/sbin/deploy-approved-release "$service" "$version"
```

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

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

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

## Проверка хоста и грамматика команд одинаково важны

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

Используйте `StrictHostKeyChecking yes` для автоматизационных идентификаторов. Передавайте проверенные отпечатки через управление конфигурацией или поддерживайте тщательно контролируемый файл `known_hosts`. При смене ключа хоста выполняйте отдельное изменение с проверкой по независимому каналу. Не учите агента отвечать «yes» на запрос ключа хоста.

Минимальный профиль клиента для неинтерактивного действия часто выглядит так:

```sshconfig
Host deploy-01.internal.example
    HostName deploy-01.internal.example
    User release
    IdentityAgent /path/to/broker.sock
    IdentitiesOnly yes
    StrictHostKeyChecking yes
    UserKnownHostsFile /Library/Application Support/agent-ssh/known_hosts
    BatchMode yes
    RequestTTY no
    ForwardAgent no
```

`IdentitiesOnly yes` не позволяет SSH отправлять серверу каждый доступный через агент идентификатор. Это уменьшает лишние попытки аутентификации и предотвращает случайное использование личного идентификатора, если нужный идентификатор действия не сработал. Руководство OpenSSH `ssh_config(5)` описывает этот параметр как способ использовать настроенные файлы идентификаторов, а не дополнительные идентификаторы агента или провайдера.

Не позволяйте агенту выбирать имена хостов, если этого можно избежать. Запрос должен ссылаться на одобренную логическую цель, например `production-api-release`, а доверенная сторона должна сопоставлять её с именем хоста, удалённой учётной записью, отпечатком, идентификатором и семейством команд. Это политика в обычном смысле, но она должна быть данными, которые человек может проверить, а не хитрым языком с лазейками.

К грамматике команд нужен такой же сдержанный подход. Произвольные аргументы фиксированного бинарного файла всё ещё могут означать произвольное выполнение, если программа принимает пути, имена плагинов, импорты конфигурации или флаги `--exec`. Изучите реальный CLI. Запишите разрешённые токены. Тестируйте неправильный ввод, пробелы, специальные символы оболочки, неожиданный Unicode и повторяющиеся флаги.

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

## Одобряйте полномочия в момент, когда они становятся полезными

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

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

Так обычная диагностика не превращается в очередь из кликов. Одновременно вы избегаете худшего варианта: одного одобрения для каждого будущего процесса с именем `node`, `python` или `claude` на машине.

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

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

Запрос на одобрение не должен заставлять пользователя читать абзац сериализованного JSON. Покажите краткое описание действия, а подробности сделайте доступными: адрес, удалённую учётную запись, фиксированное имя команды, переданные аргументы и тайм-аут. Человек быстро заметит разницу между `production-db` и `staging-db`. Но после десятого запроса он уже не сможет надёжно распознать опасность в длинной цепочке оболочки.

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

## Журнал аудита должен пережить компрометацию вызывающего процесса

Журналов, создаваемых агентом, недостаточно. Если процесс агента может записывать или удалять собственные SSH-вызовы, он оставит после себя удобную версию истории.

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

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

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

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

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

## Используйте четыре узких пути вместо одной общей оболочки

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

Большинство команд обнаруживает небольшой набор повторяющихся операций:

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

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

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

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

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