Skip to main content
Glama
perhamm

ssh-mcp-server

by perhamm
README.md
<div align="center">

# ssh-mcp-server

[![CI](https://github.com/perhamm/ssh-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/perhamm/ssh-mcp-server/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/%40perhamm%2Fssh-mcp-server?label=%40perhamm%2Fssh-mcp-server)](https://www.npmjs.com/package/@perhamm/ssh-mcp-server)

MCP-сервер поверх SSH: агент выполняет команды на удалённых машинах, а ключи, пароли и sudo остаются на нашей стороне.

Форк [classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server) под ISC.

Русский | [English](README_EN.md)

</div>

## Что это

ssh-mcp-server это мост между MCP-клиентом (Claude Code, Cursor, Cline) и SSH. Агент вызывает инструменты, сервер подключается к машине и возвращает вывод. Модель не видит ни приватного ключа, ни пароля, ни пароля sudo: всё это читается из локального конфига и переменных окружения процесса.

Один сервер обслуживает любое число хостов. Хост выбирается на лету по алиасу из `~/.ssh/config`, поэтому в конфиг MCP-клиента не нужно вписывать каждую машину.

## Что добавлено в этом форке

| Возможность | Зачем |
|---|---|
| Хосты из `~/.ssh/config` на лету | Один MCP на весь парк. Алиас передаётся в `connectionName`, соединение поднимается при первом обращении |
| ProxyJump | Хост за бастионом доступен по алиасу, цепочка `ProxyJump` разбирается из SSH-конфига |
| sudo из переменной окружения | Агент просит `sudo: true`, пароль подставляет сервер и вырезает его из вывода |
| Запретное ядро | Список операций, которые не выполняются никогда: ни под sudo, ни в любом профиле, ни через SFTP |
| Профили гвардов | Готовый набор запретов `safe` и белый список `readonly`, версионированный и обновляемый |
| Туннели | SOCKS5 (аналог `ssh -D`) и проброс порта (аналог `ssh -L`) на произвольный локальный порт |
| Проверка хост-ключей | `known_hosts` сверяется по умолчанию, ключ не из списка означает отказ подключаться |
| Современная криптография | Ed25519 первым в списке, без SHA-1, CBC и DSA |
| Аудит-лог | Каждый вызов пишется в JSONL с ротацией и gzip-архивами |
| Без загрузки файлов | `upload` по умолчанию не публикуется: файл, который гварды не прочитают, это способ занести код на хост |

## Инструменты

| Инструмент | Что делает |
|---|---|
| `execute-command` | Выполняет команду, умеет `sudo` и произвольный `connectionName` |
| `download` | Забирает файл с сервера |
| `list-servers` | Показывает настроенные соединения, их статус и активный профиль гвардов |
| `list-ssh-hosts` | Показывает алиасы из SSH-конфига, доступные как `connectionName` |
| `open-tunnel` | Поднимает SOCKS5-прокси или проброс порта через соединение |
| `close-tunnel` | Закрывает туннель |
| `list-tunnels` | Показывает открытые туннели и счётчики соединений |

`list-ssh-hosts` появляется только с флагом `--ssh-config-hosts`, туннельные инструменты убираются флагом `--disable-tunnels`. Инструмента `upload` в списке нет: он публикуется только с флагом `--enable-upload`.

## Факты о хосте без лишних команд

При подключении сервер один раз снимает состояние машины: имя, адреса, ОС, ядро, аптайм, диск, память, число процессов. Все пробы склеены маркерами в одну команду, так что это один заход по ssh, а не шесть.

Сервер держит результат в кеше и отдаёт его в `list-servers`:

```
[connected] prod-1 | deploy@10.0.0.5:22 | hostname=prod-1 | os=Linux | updated=2026-08-19T18:14:23Z

Raw JSON:
[{"name":"prod-1","connected":true,"guards":"guards=safe ruleset=2026.08.19 ...",
  "status":{"reachable":true,"osVersion":"Ubuntu 24.04.1 LTS","kernelVersion":"6.8.0-51-generic",
  "uptime":"12 days","diskSpace":{"free":"9.8G","total":"229.6G"},
  "memory":{"free":"5.6G","total":"15.5G"},"processes":{"running":214}}}]
```

То есть `uname -a`, `df -h`, `free -h` и `uptime` спрашивать не надо, ответы уже пришли. Агент вызывает `list-servers` один раз и читает статус оттуда.

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

Успешная команда без вывода возвращает `[exit code] 0`, а не пустую строку. Пустой ответ модель читает как непонятный исход и идёт перепроверять через `echo $?`, а это лишний заход на сервер и лишние токены.

## Быстрый старт: один сервер на весь парк

Конфиг MCP-клиента:

```json
{
  "mcpServers": {
    "ssh": {
      "command": "npx",
      "args": [
        "-y",
        "@perhamm/ssh-mcp-server",
        "--ssh-config-hosts",
        "--guards-profile", "safe"
      ],
      "env": {
        "SSH_MCP_SUDO_PASSWORD": "..."
      }
    }
  }
}
```

Дальше агент работает так:

1. Вызывает `list-ssh-hosts` и находит нужный алиас, например `prod-master`. У больших конфигов список режется, поэтому агент передаёт `filter`: подстроку или шаблон вида `prod-*`.
2. Вызывает `execute-command` с `connectionName: "prod-master"`.
3. Сервер читает алиас из `~/.ssh/config`, берёт оттуда `HostName`, `User`, `Port`, `IdentityFile` и `ProxyJump`, поднимает соединение и выполняет команду.

Ключ при этом не покидает машину: сервер читает файл сам, в диалог попадает только путь из SSH-конфига. Если `IdentityFile` не указан, берётся ssh-agent из `SSH_AUTH_SOCK`. Алиас без `HostName` подключается по собственному имени, как это делает `ssh`.

Достижим только тот алиас, который объявлен в конфиге отдельным блоком `Host`. Блок `Host *` даёт умолчания, но не превращает произвольное имя в достижимый хост.

Список алиасов можно сузить:

```json
"args": [
  "-y", "@perhamm/ssh-mcp-server",
  "--ssh-config-hosts",
  "--allowed-hosts", "prod-*,*-stage-*",
  "--ssh-config-file", "/home/user/.ssh/config_work"
]
```

Шаблоны поддерживают `*` и `?`. Если алиас не подходит ни под один шаблон, соединение не поднимается, а агент получает ошибку `SSH_HOST_NOT_ALLOWED`.

## Безопасный режим и гварды

Гварды это версионированный набор правил, который проверяет каждую команду до отправки на сервер. Правила лежат в [`guards/default-guards.json`](guards/default-guards.json) и обновляются вместе с репозиторием.

### Профили

| Профиль | Поведение |
|---|---|
| `off` | Правила профиля выключены, работает только запретное ядро. Значение по умолчанию |
| `safe` | Плюс запрет разрушительных команд, остальное разрешает |
| `readonly` | Разрешает только чтение и диагностику, наследует все запреты `safe` |

```json
"args": ["-y", "@perhamm/ssh-mcp-server", "--ssh-config-hosts", "--guards-profile", "safe"]
```

Что ловит `safe` сверх ядра: `shutdown` и `reboot`, сброс firewall, остановку sshd и kubelet, `kubectl delete`, `helm uninstall`, `docker system prune`, удаление пакетов, `DROP DATABASE`, `curl | sh`, `git push --force`, чистку логов, выгрузку модулей ядра, интерактивные редакторы. Полный список с причинами в JSON.

`readonly` дополнительно требует, чтобы каждая часть команды попадала в белый список: `ls`, `cat`, `grep`, `find`, `ps`, `ss`, `df`, `journalctl`, `systemctl status`, `kubectl get/describe/logs`, `docker ps/logs` и подобное. sudo в этом профиле запрещён целиком, вместе с `su`, `doas` и `pkexec`.

### Запретное ядро

Часть операций не выполняется никогда: ни в профиле `off`, ни под sudo, ни через свой файл гвардов, ни через SFTP в обход команд. Список живёт в блоке `forbidden`.

| Категория | Что закрыто |
|---|---|
| Учётные записи | `useradd`, `usermod`, `userdel`, `groupadd`, `passwd`, `chpasswd`, `chage`, `vipw` и запись в `/etc/passwd`, `/etc/shadow`, `/etc/group` |
| sudo | Запись в `/etc/sudoers` и `/etc/sudoers.d`, `visudo` |
| Расписания | `crontab` кроме `crontab -l`, запись в `/etc/cron*`, `/var/spool/cron`, `/etc/anacrontab`, команды `at` и `batch` |
| systemd | Запись юнитов и таймеров в `/etc/systemd`, `/lib/systemd`, `/usr/lib/systemd`, `systemctl edit`, `systemd-run` |
| SSH | Правка `/etc/ssh/*`, `~/.ssh/*`, `authorized_keys`, `sshd_config`, а также `ssh-keygen`, `ssh-copy-id`, `ssh-add` |
| Интерпретаторы | `python`, `perl`, `ruby`, `node`, `php`, `lua`, `Rscript` и запуск скрипта файлом: `bash /tmp/x.sh`, `sh -s`, `source`. Гварды не читают чужой код, поэтому такой запуск закрыт целиком |
| Массовое удаление | `rm -r` каталога первого уровня или системного подкаталога, `rm -r` по маске, `find -delete`, удаление через `xargs rm`, флаг `--no-preserve-root` |
| Диски и секреты | `mkfs`, `wipefs`, `dd of=/dev/`, запись в `/dev/sd*`, форк-бомба, чтение `/etc/shadow` и приватных ключей |

Обычная работа при этом остаётся: `crontab -l`, `cat /etc/ssh/sshd_config`, `systemctl restart nginx`, `rm -rf /var/lib/myapp/cache/tmp` проходят. Разбираемый `bash -c "..."` тоже работает: его содержимое проверяется теми же правилами.

Ядро закрывает и файловые инструменты. `download` не заберёт `/etc/shadow` и содержимое `~/.ssh`, и `allowedRemotePaths` тут ничего не разрешает обратно. Локальная сторона тоже под защитой: `download` не положит файл в наш собственный `~/.ssh`.

Загрузка файлов выключена совсем. Инструмент `upload` не публикуется, пока не передан `--enable-upload`, а профиль `readonly` отклоняет загрузку и с этим флагом.

Если сервер нужен именно для заведения пользователей или правки крона, ядро придётся править в форке осознанно: флага, который его снимает, нет.

### Почему это не обходится точкой с запятой

Команда разбирается на части по `;`, `|`, `&&`, `||`, `&`, переводу строки и подстановкам `$(...)`, и каждая часть проверяется отдельно. Кавычки при разборе учитываются. Так `ls; rm -rf /` не проходит ни в одном профиле, хотя целиком строка начинается с разрешённого `ls`.

Обёртки снимаются перед проверкой: `sudo`, `env`, `timeout 5`, `nohup` и присваивания вида `LC_ALL=C` не прячут команду от правил. Скрипт внутри `bash -c "..."` разбирается отдельно и проверяется теми же правилами. Длина команды ограничена 5000 символами.

Гварды закрывают ошибки агента, а не намеренный обход. Интерпретатор с произвольным кодом внутри, вроде `python -c`, правила не разберут. Там, где обход недопустим, ограничиваем права самого пользователя SSH.

### Обновление правил

Три способа держать правила свежими:

1. Мержим апстрим в свой форк. Файл правил версионирован полем `version`, версия видна в `list-servers` и в тексте отказа.
2. Держим свой файл и указываем его через `--guards-file /etc/ssh-mcp/guards.json`. Правила из него добавляются к встроенным, версия становится `2026.08.19+local-1`.
3. Обновляем файл по расписанию:

```sh
node scripts/update-guards.js https://example.com/guards.json /etc/ssh-mcp/guards.json
```

Скрипт проверяет JSON и компилирует каждое регулярное выражение и только потом заменяет файл. Битая загрузка не ломает работающий набор правил.

Формат своего файла:

```json
{
  "version": "local-1",
  "profiles": {
    "safe": {
      "deny": [
        { "id": "no-ansible", "pattern": "^ansible-playbook\\b", "reason": "выкат идёт из CI" }
      ]
    }
  }
}
```

Поле `scope: "command"` заставляет правило проверять команду целиком, а не по частям. Так работают правила про `curl | sh` и SQL. В блок `forbidden` своего файла можно дописать свои запреты, но встроенные из него не убрать: списки складываются.

Старые `--whitelist` и `--blacklist` никуда не делись и проверяются до гвардов.

## sudo без пароля в диалоге

Пароль sudo лежит в переменной окружения процесса сервера. Агент передаёт `sudo: true`, но самого пароля не видит ни в аргументах вызова, ни в выводе.

```json
{
  "mcpServers": {
    "ssh": {
      "command": "npx",
      "args": ["-y", "@perhamm/ssh-mcp-server", "--ssh-config-hosts", "--guards-profile", "safe"],
      "env": {
        "SSH_MCP_SUDO_PASSWORD": "..."
      }
    }
  }
}
```

Вызов инструмента:

```json
{
  "tool": "execute-command",
  "params": {
    "cmdString": "systemctl restart nginx",
    "connectionName": "prod-master",
    "sudo": true
  }
}
```

Команда уходит на сервер как `sudo -S -k -p '' -u root -- /bin/sh -c '<команда>'`, пароль пишется в stdin канала. В командной строке его нет, поэтому он не попадает в `ps` и в историю. Псевдотерминал для таких команд отключается, иначе tty отразил бы ввод обратно в вывод. На всякий случай пароль вырезается из вывода и из текста ошибок.

Имя переменной и целевого пользователя меняются флагами `--sudo-password-env` и `--sudo-user`. Если переменная пустая, вызов падает с `SUDO_PASSWORD_MISSING` ещё до подключения. Профиль `readonly` запрещает sudo вообще.

В режиме `shell` пароль дописывается отдельной строкой сразу за командой, так как sudo читает тот же stdin, что и сам shell. Флаг `-k` гарантирует, что запрос пароля будет всегда и строка не выполнится как команда. Для sudo лучше режим `exec`.

## Туннели

`open-tunnel` поднимает локальный слушатель и гонит трафик через SSH-соединение. Полезно, когда до сервисов кластера нет прямого доступа, а до узла есть.

SOCKS5 на порту 8777:

```json
{
  "tool": "open-tunnel",
  "params": {
    "type": "socks5",
    "localPort": 8777,
    "connectionName": "prod-master"
  }
}
```

Дальше любой клиент ходит через прокси, имена резолвятся на удалённой стороне:

```sh
curl --socks5-hostname 127.0.0.1:8777 http://prometheus.monitoring.svc:9090/api/v1/query?query=up
kubectl --request-timeout=30s ... # через HTTPS_PROXY=socks5h://127.0.0.1:8777
```

Проброс одного порта, аналог `ssh -L`:

```json
{
  "tool": "open-tunnel",
  "params": {
    "type": "local",
    "localPort": 15432,
    "remoteHost": "pg-master.internal",
    "remotePort": 5432
  }
}
```

Если `localPort` не указан, порт выбирает система и возвращает его в ответе. Туннели живут до `close-tunnel`, до обрыва SSH-соединения или до остановки сервера.

Ограничения задаются флагами:

| Флаг | Значение по умолчанию | Что делает |
|---|---|---|
| `--tunnel-bind-address` | `127.0.0.1` | Адрес, на котором слушают туннели |
| `--allowed-tunnel-ports` | нет ограничений | Список портов, которые разрешено занимать |
| `--max-tunnels` | 8 | Сколько туннелей держим одновременно |
| `--disable-tunnels` | выключено | Убирает туннельные инструменты из списка |

Слушатель по умолчанию поднимается на loopback. Адрес шире loopback открывает прокси в вашу сеть, поэтому меняем его осознанно.

## Проверка хост-ключей

Ключ сервера сверяется с `known_hosts` при каждом подключении, включая промежуточные хосты цепочки `ProxyJump`. По умолчанию режим `strict`: хоста нет в `known_hosts` означает отказ.

| Режим | Поведение |
|---|---|
| `strict` | По умолчанию. Подключаемся только к хостам из `known_hosts` |
| `accept-new` | Незнакомый хост записывается при первом подключении, расхождение ключа по-прежнему отказ |
| `off` | Проверки нет, поведение апстрима |

Проверяются `~/.ssh/known_hosts`, `~/.ssh/known_hosts2` и `/etc/ssh/ssh_known_hosts`, а для алиаса с `UserKnownHostsFile` тот файл, который указан в SSH-конфиге. Свой список задаётся флагом `--known-hosts-file`. Понимаются хешированные записи, шаблоны, форма `[host]:port` и метка `@revoked`.

Отказ приходит кодом `SSH_HOST_KEY_REJECTED` и текстом с отпечатком:

```text
Host key of prod.example.com is not in known_hosts (~/.ssh/known_hosts): ssh-ed25519 SHA256:xxxx.
Verify that fingerprint, add the host to known_hosts, or start the server with --host-key-checking accept-new.
```

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

Для первого знакомства с парком удобно один раз пройти с `--host-key-checking accept-new`, а дальше вернуть `strict`.

## Аудит-лог

Каждый вызов пишется строкой JSON: команда, соединение, флаг sudo, вердикт гварда, длительность, объём вывода. Содержимое вывода в лог не попадает, пароль sudo вырезается.

```json
{"time":"2026-08-19T08:12:44.101Z","pid":8123,"event":"command","result":"blocked","connection":"prod-master","command":"useradd deploy","sudo":true,"code":"COMMAND_VALIDATION_FAILED","reason":"Blocked by the forbidden core ..."}
{"time":"2026-08-19T08:12:51.880Z","pid":8123,"event":"command","result":"ok","connection":"prod-master","command":"systemctl status nginx","sudo":false,"durationMs":412,"bytes":1840}
```

Пишутся события `connect`, `command`, `download`, `upload`, `tunnel-open`, `tunnel-close`, `host-key`.

По умолчанию файл лежит в `$XDG_STATE_HOME/ssh-mcp-server/audit.jsonl`, то есть обычно `~/.local/state/ssh-mcp-server/audit.jsonl`, права `0600`.

| Флаг | По умолчанию | Что делает |
|---|---|---|
| `--audit-log <path>` | каталог состояния XDG | Путь к логу, значение `off` выключает запись |
| `--audit-max-size <bytes>` | 10485760 | Размер, после которого файл ротируется. `0` отключает встроенную ротацию |
| `--audit-keep <count>` | 10 | Сколько gzip-архивов держим |

Ротация встроенная: по достижении лимита текущий файл переезжает в `audit.jsonl.1.gz`, старые архивы сдвигаются, всё за пределами `--audit-keep` удаляется. Десять архивов по 10 МиБ это порядка сотни мегабайт в несжатом виде и заметно меньше после gzip.

Если логами уже управляет logrotate, ставим `--audit-max-size 0` и настраиваем ротацию режимом `copytruncate`.

Ошибка записи не роняет команду: сервер один раз пишет об этом в stderr и продолжает работать.

## Способы подключения

Ниже сценарии от простого к сложному. В `args` каждый флаг и его значение это два отдельных элемента массива: `"--host", "192.168.1.1"`, а не `"--host 192.168.1.1"`.

### Логин и пароль

```json
{
  "mcpServers": {
    "ssh-mcp-server": {
      "command": "npx",
      "args": [
        "-y",
        "@perhamm/ssh-mcp-server",
        "--host", "192.168.1.1",
        "--port", "22",
        "--username", "root",
        "--password", "pwd123456"
      ]
    }
  }
}
```

### Приватный ключ

```json
"args": [
  "-y", "@perhamm/ssh-mcp-server",
  "--host", "192.168.1.1",
  "--username", "root",
  "--privateKey", "~/.ssh/id_rsa",
  "--passphrase", "pwd123456"
]
```

Пароль от ключа можно не писать в конфиг, а положить в переменную `SSH_MCP_PASSPHRASE`.

### Один алиас из `~/.ssh/config`

```json
"args": ["-y", "@perhamm/ssh-mcp-server", "--host", "myserver"]
```

Сервер читает `HostName`, `Port`, `User`, `IdentityFile` и `ProxyJump` из блока `Host myserver`, включая директивы `Include` и шаблоны. Флаги командной строки приоритетнее: `--port 2222` перебивает порт из конфига.

### Бастион и ProxyJump

Если у алиаса есть `ProxyJump`, цепочка поднимается сама:

```
Host prod-master
    HostName 10.20.30.40
    User ops
    ProxyJump bastion
    IdentityFile ~/.ssh/prod_key
```

Каждый следующий хоп подключается через канал предыдущего, как это делает `ssh -J`. Цепочку можно задать и вручную: `--proxy-jump "bastion,gateway:2222"`. Глубина цепочки ограничена пятью хопами.

Форма `ProxyCommand ssh bastion -W %h:%p` понимается как эквивалент `ProxyJump bastion`: именно в неё ssh разворачивает короткую запись, а в сгенерированных конфигах она встречается чаще. Флаги `-l` и `-p` у хопа учитываются, `%r` подставляется пользователем целевого хоста. Остальные варианты `ProxyCommand` игнорируются: своего процесса сервер не запускает.

Если хост достижим только через хоп, которого в SSH-конфиге нет, цепочку задаёт сам вызов. Параметр `proxyJump` есть у `execute-command`, `download` и `open-tunnel`, он перекрывает цепочку из конфига. Это нужно там, где конфиг генерится автоматикой и править его бессмысленно, правку затрёт следующая выкатка.

```json
{
  "tool": "execute-command",
  "params": {
    "cmdString": "hostname",
    "connectionName": "legacy-master",
    "proxyJump": "prod-master-0"
  }
}
```

Такое соединение живёт под собственным именем вида `алиас via цепочка`, поэтому один алиас, поднятый двумя маршрутами, не схлопывается в одно соединение и виден в `list-servers` двумя строками. Каждый хоп обязан быть алиасом из конфига, иначе вызывающая сторона могла бы направить сервер на произвольный адрес. На чужой хоп приходит `SSH_JUMP_NOT_ALLOWED`.

### Прокси

```json
"args": [
  "-y", "@perhamm/ssh-mcp-server",
  "--host", "192.168.1.1",
  "--username", "root",
  "--password", "pwd123456",
  "--proxy", "socks5://user:pwd@proxy-host:1080"
]
```

Поддерживаются `socks://`, `socks5://`, `http://` и `https://`. HTTP и HTTPS ходят методом `CONNECT` с Basic-аутентификацией, порт по умолчанию 80 и 443. Для SOCKS5 порт обязателен. Старый флаг `--socksProxy` работает, но принимает только SOCKS. Вместе `--proxy` и `--proxy-jump` не используются.

### Джамп-хост с интерактивным shell

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

```json
"args": [
  "-y", "@perhamm/ssh-mcp-server",
  "--host", "bastion.example.com",
  "--username", "ops",
  "--password", "pwd123456",
  "--transport-mode", "shell",
  "--shell-ready-timeout", "15000"
]
```

В режиме `shell` команды идут по очереди через одну постоянную сессию, а `upload` и `download` не работают: SFTP там отключён.

### Двухфакторная аутентификация

Флаг `--try-keyboard` включает keyboard-interactive. Пароль и ключ подставляются сами, код из второго фактора читается из переменной `SSH_MCP_2FA_CODE`.

### Несколько соединений в одном сервере

Кроме алиасов из SSH-конфига остаётся старый способ: файл с описанием соединений.

```json
[
  {
    "name": "dev",
    "host": "1.2.3.4",
    "port": 22,
    "username": "alice",
    "privateKey": "~/.ssh/dev_key",
    "guardProfile": "safe",
    "commandTimeoutMs": 120000
  },
  {
    "name": "prod",
    "host": "5.6.7.8",
    "port": 22,
    "username": "bob",
    "privateKey": "~/.ssh/prod_key",
    "guardProfile": "readonly",
    "allowedRemotePaths": ["/var/log", "/tmp"]
  }
]
```

```json
"args": ["-y", "@perhamm/ssh-mcp-server", "--config-file", "/abs/path/ssh-config.json"]
```

Формат объекта, где ключ это имя соединения, тоже поддерживается. Соединение выбирается параметром `connectionName`, без него берётся первое.

## Ограничения команд и путей

### Белый и чёрный списки

```json
"args": [
  "-y", "@perhamm/ssh-mcp-server",
  "--host", "192.168.1.1",
  "--username", "root",
  "--privateKey", "~/.ssh/id_rsa",
  "--whitelist", "^ls( .*)?,^cat .*,^df.*",
  "--blacklist", "^rm .*,^shutdown.*"
]
```

Шаблоны это регулярные выражения через запятую. Команда сначала проверяется по белому списку, потом по чёрному, потом по профилю гвардов, и должна пройти все три проверки.

### Шаблон команды

`--command-template` оборачивает каждую команду. `<quotedCommand>` подставляет команду как экранированный аргумент, `<command>` вставляет её как есть. Шаблон применяется после подстановки рабочего каталога.

```
su root -c <quotedCommand>
docker exec -i mycontainer sh -c <quotedCommand>
```

### Пути для файловых операций

`--allowed-local-paths` расширяет список локальных каталогов, доступных для `upload` и `download` (по умолчанию только текущий каталог). `--allowed-remote-paths` ограничивает удалённые пути, туда пишем абсолютные POSIX-пути через запятую. Без этого флага SFTP видит всю файловую систему хоста, о чём сервер предупреждает при старте.

## Таймауты и лимит вывода

| Параметр | По умолчанию | Что ограничивает |
|---|---|---|
| `timeout` в вызове инструмента | нет | Одну команду, перебивает настройки соединения |
| `commandTimeoutMs` | 30000 | Команду в режиме `exec` |
| `shellCommandTimeoutMs` | 30000 | Команду в режиме `shell` |
| `connectionTimeoutMs` | 30000 | Установку соединения и хендшейк |
| `sftpTimeoutMs` | 300000 | Операции SFTP |
| `maxOutputBytes` | 10485760 | Захваченный вывод одной команды |
| `keepaliveIntervalMs` | 10000 | Интервал keepalive |

При превышении лимита вывода команда обрывается, а инструмент возвращает `OUTPUT_LIMIT_EXCEEDED` вместе с уже собранным куском. Ошибки приходят структурой из `code`, `message` и `retriable`.

## Флаги командной строки

```text
  --config-file <path>             Файл с описанием соединений
  --ssh-config-file <path>         Путь к SSH-конфигу (по умолчанию ~/.ssh/config)
  --ssh <config>                   Соединение как JSON или пары key=value
  -h, --host <host>                Хост или алиас из SSH-конфига
  -p, --port <port>                Порт
  -u, --username <name>            Пользователь
  -w, --password <password>        Пароль
  -k, --privateKey <path>          Путь к приватному ключу
  -P, --passphrase <passphrase>    Пароль от ключа
  -a, --agent <path>               Сокет ssh-agent
  -W, --whitelist <patterns>       Белый список команд, через запятую
  -B, --blacklist <patterns>       Чёрный список команд, через запятую
  --proxy <url>                    Прокси SOCKS5, HTTP или HTTPS
  -s, --socksProxy <url>           Старый флаг только для SOCKS5
  --allowed-local-paths <paths>    Локальные каталоги для upload и download
  --allowed-remote-paths <paths>   Удалённые каталоги для SFTP
  --transport-mode <mode>          exec или shell (по умолчанию exec)
  --shell-ready-timeout <ms>       Таймаут готовности shell (по умолчанию 10000)
  --command-template <template>    Шаблон с <command> или <quotedCommand>
  --pty                            Псевдотерминал для exec (по умолчанию включён)
  --try-keyboard                   Keyboard-interactive для 2FA
  --pre-connect                    Подключиться ко всем хостам при старте
  --ssh-config-hosts               Разрешить хосты из SSH-конфига на лету
  --allowed-hosts <patterns>       Шаблоны разрешённых алиасов, через запятую
  --proxy-jump <chain>             Цепочка ProxyJump, через запятую
  --guards-profile <name>          off, safe или readonly (по умолчанию off)
  --guards-file <path>             Свой набор правил поверх встроенного
  --sudo-password-env <var>        Переменная с паролем sudo
  --sudo-user <user>               Пользователь для sudo (по умолчанию root)
  --host-key-checking <mode>       strict, accept-new или off (по умолчанию strict)
  --known-hosts-file <paths>       Свои файлы known_hosts, через запятую
  --host-key-algorithms <list>     Алгоритмы хост-ключа, через запятую
  --enable-upload                  Опубликовать инструмент upload (по умолчанию выключен)
  --audit-log <path|off>           Путь к аудит-логу (по умолчанию каталог состояния XDG)
  --audit-max-size <bytes>         Порог ротации, 0 отключает (по умолчанию 10485760)
  --audit-keep <count>             Сколько архивов держим (по умолчанию 10)
  --disable-tunnels                Убрать туннельные инструменты
  --tunnel-bind-address <addr>     Адрес для туннелей (по умолчанию 127.0.0.1)
  --allowed-tunnel-ports <ports>   Разрешённые порты туннелей, через запятую
  --max-tunnels <count>            Лимит одновременных туннелей (по умолчанию 8)
  --version, -v                    Версия пакета
  --help                           Справка
```

## Безопасность

- Для прода включаем `--guards-profile safe`, для дежурного разбора инцидентов подходит `readonly`. С `off` остаётся только запретное ядро: всё остальное выполнится, о чём сервер и пишет предупреждение в лог.
- Ключ, его пароль и пароль sudo читаются из файлов и переменных окружения. В конфиге MCP-клиента храним путь к ключу, а не сам ключ.
- Туннели слушают loopback. У SOCKS5 нет аутентификации, поэтому прокси на `0.0.0.0` открывает внутреннюю сеть всем, кто дотянется до порта, и сервер пишет об этом предупреждение при старте.
- Без `--allowed-remote-paths` через SFTP читается и пишется любой путь на хосте, включая `~/.ssh/authorized_keys`.
- Ключ хоста сверяется с `known_hosts` в режиме `strict`. Снимать проверку через `--host-key-checking off` стоит только в лаборатории.
- Ограничений частоты вызовов нет.

## Разработка

```sh
npm install
npm run build
npm test
```

Тесты запускаются встроенным раннером Node.js и лежат в [`test/`](test/).

## Апстрим и лицензия

Проект вырос из [classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server) (автор junki.cn), лицензия ISC. Копирайт апстрима сохранён в [LICENSE](LICENSE), там же ссылка на исходный репозиторий.

Набор гвардов частично собран по идеям [tufantunc/ssh-mcp](https://github.com/tufantunc/ssh-mcp) (MIT).

Пакет в NPM: [@perhamm/ssh-mcp-server](https://www.npmjs.com/package/@perhamm/ssh-mcp-server).