# Могут ли принудительные SSH-команды содержать учетную запись AI-сервиса?

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

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

## Принудительная команда ограничивает выполнение, но не аутентификацию

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

OpenSSH поддерживает этот контроль в двух местах. К одному открытому ключу в `authorized_keys` можно добавить `command="/path/to/wrapper"`, либо задать `ForceCommand` в `sshd_config` для пользователя или группы. В обоих случаях `sshd` сохраняет запрошенную клиентом команду в переменной окружения `SSH_ORIGINAL_COMMAND`, а затем запускает принудительную программу.

В руководстве OpenSSH `sshd(8)` прямо сказано: параметр `command` заставляет выполнить указанную команду после аутентификации. Там же описано, что исходная команда остается доступной этой принудительной программе. Именно на этой детали часто ломаются слабые решения. Оболочка получает строку от недоверенного клиента. Она должна разобрать строку как запрос, а не передать ее оболочке.

Используйте вариант для отдельного ключа, когда у одной учетной записи есть несколько тщательно разделенных учетных данных. Ключ для выпуска может запускать оболочку развертывания, а ключ оператора, только для чтения, может запускать диагностическую оболочку. Так намерение остается видимым в `authorized_keys`, а один ключ можно удалить, не меняя остальной доступ учетной записи.

Используйте `ForceCommand`, когда сама учетная запись ни при каких обстоятельствах не должна предоставлять общий shell, независимо от способа аутентификации. Это касается и пароля, который вы забыли отключить, будущего центра сертификации или администратора, добавившего еще один открытый ключ без нужных параметров. Блок `Match User deploy` помогает не пропустить это правило при проверке.

Не превращайте таким способом человеческую административную учетную запись в учетную запись автоматизации. Для ремонта людям рано или поздно понадобится настоящий shell. Выдайте автоматизации отдельную Unix-учетную запись, отдельные учетные данные, отдельную оболочку команд и границы владения, соответствующие задаче.

## Сервер должен владеть точкой входа в развертывание

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

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

```text
restrict,command="/usr/local/libexec/release-gate" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

Параметр `restrict` удобен: согласно документации OpenSSH, это сокращение, отключающее перенаправление портов, перенаправление агента, X11-forwarding и выделение псевдотерминала. Точное поведение зависит от поддерживаемых вашей версией OpenSSH параметров сервера, поэтому проверьте его на используемой версии. Если для проверки или совместимости нужны явные параметры, запишите их полностью:

```text
command="/usr/local/libexec/release-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

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

```text
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
```

Безопасная оболочка может разрешить только такой синтаксис:

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

request=${SSH_ORIGINAL_COMMAND-}
case "$request" in
  "release "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* )
    revision=${request#release }
    case "$revision" in
      *" "*|*[!0-9a-f]*)
        echo "invalid revision" >&2
        exit 64
        ;;
    esac
    exec /usr/local/libexec/run-release "$revision"
    ;;
  *)
    echo "unsupported remote request" >&2
    exit 64
    ;;
esac
```

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

Скрипт `run-release` должен использовать абсолютные пути и сам задавать окружение. Не полагайтесь на переданные вызывающей стороной `PATH`, рабочий каталог, локаль, `GIT_DIR`, `GIT_SSH_COMMAND` или `LD_PRELOAD`. Минимальное начало может выглядеть так:

```sh
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
unset CDPATH ENV BASH_ENV GIT_DIR GIT_WORK_TREE GIT_SSH_COMMAND
cd /srv/release-repo

revision=$1
/usr/bin/git cat-file -e "$revision^{commit}"
/usr/local/libexec/build-and-activate "$revision"
```

Учетная запись должна владеть только теми файлами, которые ей необходимо менять. Если ей нужно перезапускать службу, выдайте одну узкую команду в `sudoers` с фиксированными аргументами, а не доступ без пароля к универсальному менеджеру пакетов или shell. Учетная запись развертывания, которая может изменить собственную оболочку, свою запись `authorized_keys` или unit, запускающий ее код, обычно способна вернуть себе широкий контроль. Проверяйте эти пути, а не только конфигурацию SSH.

## `SSH_ORIGINAL_COMMAND` это ввод, а не командная строка

Самая частая ошибка с принудительными командами выглядит так:

```sh
sh -c "$SSH_ORIGINAL_COMMAND"
```

Эта строка отменяет установленное вами ограничение. Клиент может запросить `release goodrev; curl ... | sh`, подстановку команд, перенаправленный вывод или специально составленный аргумент, который попадет в привилегированный инструмент. Оболочка, вызывающая `eval`, `sh -c`, `bash -c` или раскрытие без кавычек, возвращает удаленный shell-доступ под другим именем файла.

Не пытайтесь создавать полноценный парсер shell. Он вам не нужен. Определите намеренно небольшой протокол и отклоняйте все, что в него не входит. Для учетной записи развертывания запросом может быть один глагол и один идентификатор. Для диагностической учетной записи это может быть одно точное слово, например `health` или `version`.

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

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

case "${SSH_ORIGINAL_COMMAND-}" in
  health)
    exec /usr/local/libexec/report-health
    ;;
  queue-depth)
    exec /usr/local/libexec/report-queue-depth
    ;;
  version)
    exec /usr/local/libexec/report-version
    ;;
  "")
    echo "a diagnostic name is required" >&2
    exit 64
    ;;
  *)
    echo "diagnostic is not allowed" >&2
    exit 64
    ;;
esac
```

Эти диагностические скрипты тоже должны сами контролировать аргументы. `report-health` должен вызывать фиксированные бинарные файлы для фиксированных локальных сокетов или известных имен служб. Он не должен принимать имя хоста и запускать `curl "$host"`, а также принимать фильтр журнала и передавать его shell. Даже команда только для чтения может раскрыть учетные данные базы данных, внутреннюю топологию, значения окружения или данные клиентов.

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

Если нужны структурированные данные, передавайте ограниченный формат и разбирайте его парсером, который отклоняет лишние поля. JSON сам по себе не становится безопаснее, если shell-оболочка все равно обрабатывает его неправильно. Небольшой запрос вроде `release <64 lowercase hex characters>` проще проверить, описать, протестировать и проаудировать, чем JSON с необязательными полями.

## Перенаправление может обойти смысл ограничения

Принудительная команда сама по себе не мешает аутентифицированному клиенту использовать SSH как туннель. В руководстве OpenSSH выполнение команд и перенаправление рассматриваются как отдельные элементы управления. Если добавить только `command="..."`, клиент все еще может попросить `sshd` перенаправить локальный порт к внутренней службе, в зависимости от остальных настроек сервера.

Это важно, потому что у ограниченной учетной записи может быть сетевой доступ, которого нет у агента. Агент, который не может выполнить `/usr/bin/ps` на удаленном хосте, все еще может достучаться через этот хост до порта базы данных. В таком случае учетная запись перестает быть идентификатором развертывания и превращается в сетевой плацдарм.

Для учетной записи, которой не нужна интерактивная сессия, запретите все перечисленное, если не можете объяснить, зачем это нужно:

- TCP-перенаправление
- перенаправление агента
- X11-forwarding
- выделение псевдотерминала
- переменные окружения, которыми управляет пользователь

В современных установках OpenSSH `restrict` обрабатывает первые четыре категории. Если учетной записи действительно нужно одно исключение, не снимайте весь набор ограничений. OpenSSH поддерживает такие параметры, как `permitopen="host:port"`, чтобы ограничить адрес назначения при перенаправлении. Рассматривайте это как отдельную модель доступа и проверяйте как разрешенные, так и запрещенные адреса.

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

## Подтверждение должно происходить до открытия соединения

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

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

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

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

Не путайте карточку подтверждения с авторизацией на сервере. Подтверждение отвечает на вопрос: «Может ли этот процесс использовать эти учетные данные сейчас?» Сервер отвечает на другой вопрос: «Что могут делать эти учетные данные после входа?» Нужны оба ответа, потому что риски разные. Подтверждение может остановить неожиданный процесс. Принудительные команды могут помешать уже одобренному процессу превратить ключ выпуска в shell.

Сделайте описание подтверждения полезным. Указывайте в метке окружение и действие, например `production release` или `staging diagnostics`. Метка `deploy-key-2` заставляет проверяющего вспоминать историю в момент прерывания. Так обычные подтверждения превращаются в автоматические нажатия.

## Разделяйте развертывание и диагностику до роста списка разрешений

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

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

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

Объединенная учетная запись часто начинается с безобидного списка:

```text
release <revision>
health
logs <service>
restart <service>
```

Затем кому-то нужны `logs api --since`, срочный перезапуск, и оболочка начинает передавать аргументы в `journalctl` или `systemctl`. Вскоре код наполняется особыми случаями, которые никто не может объяснить. Разделите учетные записи заранее. Отдельные учетные данные позволяют требовать более строгого подтверждения изменений в production и оставить диагностический процесс менее рискованным.

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

## Проверяйте отказы с одноразового клиента

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

```sh
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
ssh release@deploy.example
ssh release@deploy.example "id"
ssh release@deploy.example "release 9f2a; id"
ssh -N -L 15432:db.internal:5432 release@deploy.example
ssh -tt release@deploy.example "health"
```

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

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

В рамках проверки изучите права и владельцев файлов учетной записи. Злоумышленнику, способному заменить `/usr/local/libexec/release-gate`, не нужно обходить SSH. Учетная запись, которая может изменить собственный источник развертывания, может также изменить код, запускаемый с большими привилегиями. Проверьте всю цепочку: `authorized_keys`, конфигурацию sshd, оболочку, скрипты развертывания, определения служб, доступные для записи каталоги и все записи `sudoers`.

## Аудитируйте запрос по обе стороны границы

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

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

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

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

Первая реализация должна быть скучной. Создайте отдельную Unix-учетную запись, одну оболочку с принудительной командой, одну операцию с фиксированным синтаксисом ввода, отключите перенаправление и добавьте тест, доказывающий, что `ssh account@host` не открывает shell. Добавляйте возможности только тогда, когда можете назвать их ввод, вывод, доступ к файлам, сетевой доступ и человека, который должен их подтвердить.
