Skip to main content
Glama
README.md
# sshmcp

让 AI 通过 SSH 操作你的服务器,但**全程看不到你的凭据**。

AI 只能看到主机别名,用别名下发命令和文件操作。私钥、登录密码、私钥口令、sudo 密码
存在本机的加密 vault 里,由你在终端管理;sshmcp 的任何工具都不接受、不返回、不记录这些值。

- **20 个工具**:执行命令、批量执行、持久 shell 会话、命令模板、日志 tail、文件读写、精确替换、目录增删、移动、上传下载
- **跨平台**:Windows / Linux / macOS 同一套加密 vault,Linux 不需要 dbus 或 SecretService
- **双传输**:stdio(客户端拉起本地进程)或 streamable HTTP(带 Bearer 鉴权)
- **命令完全放开**,只做全量审计;不拦 `rm -rf`(见下方风险提示)
- **341 项测试**,其中端到端门禁跑真实 SSH 协议 + 真实 MCP JSON-RPC,CI 覆盖 Windows 与 Linux
- **密码明文输入**:提示直接显示输入内容,粘贴照常可用,绕开 Windows `getpass` 在 Git Bash 下读不到粘贴的坑

---

## 安全边界:AI 能看到什么,看不到什么

| | AI 可见 | AI 不可见 |
| --- | --- | --- |
| 主机 | 别名、描述、标签;host/port/user(可配置隐藏) | 私钥路径、vault 内容 |
| 认证 | 配了哪几种**方式**(`key_file` / `agent` / `vault`) | 私钥内容、密码、口令、sudo 密码 |
| 命令 | 自己下发的命令、exit code、stdout/stderr | 注入 stdin 的 sudo 密码 |
| 文件 | 远端文件内容(脱敏后) | 其中的凭据与私钥块 → `[REDACTED]` |
| 本地 | `local_roots` 内的上传下载 | state 目录、`~/.ssh`(硬禁,即使写进 `local_roots`) |
| 主机密钥 | 已固定的指纹 | 无「接受新密钥」的工具,密钥变更只能由你处理 |

实现上的四条硬约束:

1. **工具签名里没有任何凭据参数**,也不存在名字含 vault / config / credential / secret / key / token / audit 的工具。这两点有专门的测试断言。
2. **凭据管理只有 CLI 一条路**,秘密通过明文提示或剪贴板输入,不进命令行参数、不进 shell 历史。
3. **所有出口过脱敏器**:stdout/stderr、文件内容、异常消息、审计日志。启动时把 vault 里全部秘密值注册为字面量,另有正则拦截 PEM / OpenSSH / PuTTY 私钥块(含被输出截断后只剩头部的残块)。
4. **主机密钥 TOFU 固定**在独立的 `known_hosts`,指纹变更直接拒绝连接。

审计记录每次连接、命令、文件操作,以及**被拒绝**的连接(主机密钥变更、认证失败)和所有报错的工具调用。

### 这个保证的边界在哪(请务必读)

sshmcp 保证的是「**它自己**不把凭据交给模型」。它不能约束你的 MCP 客户端还给 AI 开了什么别的工具。

如果你的客户端同时给了 AI **本地 shell 或任意文件读取**能力,AI 理论上可以读到主密码文件,
再自己解密 vault。要把这条路也堵上,选一个:

- **最彻底**:不用 vault,改用 `--identity-file` + ssh-agent。私钥完全不经过 sshmcp,
  主密码这种东西根本不存在。
- 把 state 目录放在 AI 工作区之外,并且不给 AI 该目录的读权限。
- 只给 AI sshmcp 这一组工具,不给本地 shell。

另外几点需要知道:

- 解密后的凭据在**进程内存**中。Python 无法可靠擦除字符串,这是语言层面的限制。
- Windows 上文件权限继承目录 ACL,不像 POSIX 的 `0600` 那样强制。state 目录请放在你自己的用户目录下。
- `redact_private_keys: false` 只放开「vault 不认识的」私钥;vault 里自己的凭据始终脱敏。
- 密码短于 4 个字符时,脱敏会替换掉输出里所有同样的片段,可能让输出很难读。CLI 会警告。

### 命令权限风险

按你的选择,命令面**完全放开**:AI 可以执行 `rm -rf /`、`mkfs`、`shutdown`。
sshmcp 不拦,只记账。降低风险的做法:

- 用低权限账号登录,需要提权时才 `sudo=true`,并单独存 sudo 密码
- 给关键主机设 `host_key_policy: "strict"`
- 定期看 `sshmcp audit tail`
- 在远端用 `authorized_keys` 的 `command=` / `restrict` 选项做服务端限制

---

## 安装

需要 Python 3.11+。

```bash
git clone https://github.com/boyunxi/sshmcp.git sshmcp && cd sshmcp
python -m venv .venv
```

Windows(Git Bash):

```bash
source .venv/Scripts/activate
pip install -e .
```

Linux / macOS:

```bash
source .venv/bin/activate
pip install -e .
```

装完有一个 `sshmcp` 命令,也可以用 `python -m sshmcp`。

---

## 快速开始

### 1. 初始化

```bash
sshmcp init
```

会创建 state 目录、`config.json` 和加密 vault,并让你设一个主密码(至少 8 位)。

只想用 ssh-agent 或现有私钥文件、不想管主密码:

```bash
sshmcp init --no-vault
```

### 2. 加一台主机

密码登录(`--password` 是**触发交互提示的开关**,不是密码本身):

```bash
sshmcp host add prod-web --hostname 203.0.113.10 --user deploy --password \
  --description "生产 Web" --tag prod
```

用现有私钥文件(只存路径,私钥仍由 ssh 自己读):

```bash
sshmcp host add prod-web --hostname 203.0.113.10 --user deploy \
  --identity-file ~/.ssh/id_ed25519
```

把私钥导入加密 vault(之后原文件可以不在本机):

```bash
sshmcp host add prod-web --hostname 203.0.113.10 --user deploy \
  --private-key ~/.ssh/id_ed25519 --passphrase
```

需要 sudo 的话补一条(对已存在的别名是**合并更新**,只改动你给出的字段,其它保持原样):

```bash
sshmcp host add prod-web --sudo-password
```

脚本化场景用 JSON 从 stdin 喂:

```bash
echo '{"password":"...","sudo_password":"..."}' | \
  sshmcp host add prod-web --hostname 203.0.113.10 --user deploy --secrets-stdin
```

### 3. 验证连通

```bash
sshmcp host test prod-web
sshmcp doctor
```

`host test` 首次连接会按 TOFU 固定主机密钥。想显式核对指纹再固定:

```bash
sshmcp host trust prod-web --fingerprint SHA256:xxxxx
```

### 4. 接到 MCP 客户端

```bash
sshmcp mcp-config
```

输出可直接贴进客户端配置。**stdio 模式不能交互输入主密码**(stdin 被 JSON-RPC 占用),
所以生成的配置用 `SSHMCP_VAULT_PASSWORD_FILE` 指向一个密码文件。按提示创建它:

```bash
# state 目录路径见 sshmcp paths
printf '%s' '你的主密码' > ~/.config/sshmcp/vault-password
chmod 600 ~/.config/sshmcp/vault-password
```

Windows 上路径是 `%APPDATA%\sshmcp\vault-password`,请把该文件的权限限制为只有你的账号。

生成的配置形如:

```json
{
  "mcpServers": {
    "sshmcp": {
      "command": "/path/to/.venv/bin/python",
      "args": ["-m", "sshmcp", "serve", "--transport", "stdio", "--home", "/path/to/state"],
      "env": { "SSHMCP_VAULT_PASSWORD_FILE": "/path/to/state/vault-password" }
    }
  }
}
```

Qoder / Claude Desktop / Cursor 等支持 `mcpServers` 格式的客户端都能直接用。
接好后让 AI 先调 `ssh_hosts` 看有哪些别名。

### HTTP 模式

想让多个客户端或远程 IDE 连同一个实例:

```bash
sshmcp serve --transport http --http-host 127.0.0.1 --http-port 8765
sshmcp token show          # 取 Bearer token
sshmcp mcp-config --transport http
```

默认只绑回环地址。要绑非回环地址,必须在 `config.json` 的 `http.allowed_hosts`
里写上客户端会用的域名,否则 DNS rebinding 保护会拒掉请求(返回 421)。

---

## 密码输入

密码提示是**明文**的:直接读一行,输入的字符会显示在屏幕上,粘贴照常可用。

```bash
sshmcp init
# vault 主密码:███
# 再次输入主密码:███

sshmcp host add prod-web --hostname 203.0.113.10 --user deploy --password
# 登录密码:███
```

CLI 的所有提示、状态输出和帮助文本都是中文;底层异常(vault / 配置 / SSH 层)保持英文,
因为同一批消息也会作为工具结果返回给 MCP 客户端。

之所以不用隐藏输入:Windows 上 `getpass` 绑定的是 `win_getpass`,用 `msvcrt.getwch()`
从**控制台 CONIN$** 逐字符读,而不是从 `sys.stdin` 读。在 Git Bash / mintty 里 stdin 是管道,
你粘贴的内容进了管道,`getwch()` 却在等控制台输入——粘贴会丢失,或者直接卡死。
明文读一行在所有终端下都稳。

需要知道的取舍:

- 密码会显示在屏幕上,并留在终端滚动缓冲里。别在录屏或共享会话里用。
- 不会进 shell 历史,也不会出现在命令行参数里(`--password` 只是触发提示的开关)。
- 提示语走 stderr,所以 `mcp-config` 之类输出 JSON 的命令不会被污染。

如果你不想让它出现在滚动缓冲里,用剪贴板:

```bash
# 主机密码 / 私钥口令 / sudo 密码,各自独立
sshmcp host add prod-web --hostname 203.0.113.10 --user deploy --password-clipboard
sshmcp host add prod-web --sudo-password-clipboard
sshmcp host add prod-web --passphrase-clipboard

# vault 主密码(--clipboard 是全局选项,适用于任何需要解锁的命令)
sshmcp init --clipboard
sshmcp host show prod-web --clipboard
sshmcp vault passwd --new-clipboard      # 新主密码从剪贴板读
```

后端:Windows 用 ctypes 调原生 Win32 剪贴板 API(零新依赖);macOS 用 `pbpaste`;
Linux 依次尝试 `wl-paste`(Wayland)、`xclip`、`xsel`。都没有时会报错,去掉 `--clipboard` 直接输入即可。

剪贴板只去掉末尾的换行,**不会**动密码本身首尾的空格。读到的值不会出现在任何输出或错误信息里,
只告诉你读到了多少个字符,方便你确认复制对了。

> 注意:读完后密码仍留在剪贴板里。sshmcp 不会替你清空——那会破坏你的剪贴板内容。

### 什么时候会提示

| 场景 | 行为 |
| --- | --- |
| 解锁已有 vault | `--vault-password-file` / `SSHMCP_VAULT_PASSWORD_FILE` / `SSHMCP_VAULT_PASSWORD` 有值就不提示;否则明文提示 |
| `init` 新建 vault | 不读 env(你要设的是新密码)。用 `--vault-password-file` 可免二次确认,否则提示两次 |
| `vault passwd` 改主密码 | 提示两次(新密码 + 确认),或 `--new-clipboard` |
| `--clipboard` | 明确指定来源,任何情况下都优先于 env |

### JSON 从 stdin 喂(脚本化)

```bash
echo '{"password":"...","sudo_password":"..."}' | \
  sshmcp host add prod-web --hostname 203.0.113.10 --user deploy --secrets-stdin
```

`--secrets-stdin` 不能和提示类或剪贴板类开关混用;同一个字段的 `--password` 和
`--password-clipboard` 也互斥,都会直接报错而不是静默选一个。

`serve` 拒绝 `--clipboard`——服务进程不该交互读秘密,请用密码文件或环境变量。

---

## 工具清单

多数工具的第一个参数是 `host`(别名);`ssh_batch_exec` 收 `hosts` 列表,
`ssh_session_send` / `ssh_session_close` 收 `session_id`,`ssh_templates` 和 `ssh_hosts` 无参数。

| 工具 | 说明 |
| --- | --- |
| `ssh_hosts` | 列出别名与非秘密元数据。先调这个 |
| `ssh_ping` | 连通性、远端 uname、已固定的主机密钥指纹 |
| `ssh_exec` | 执行命令。`timeout` / `cwd` / `sudo` / `stdin` / `max_output_bytes` |
| `ssh_batch_exec` | 一次对多台主机跑同一命令,逐台返回结果 + 成功/失败汇总 |
| `ssh_session_open` | 开一个持久 `/bin/sh`,拿 `session_id`。可选 `cwd` |
| `ssh_session_send` | 在会话里跑一条命令,状态(cd/export)保留,返回 `stdout` / `stderr` / `exit_code` |
| `ssh_session_close` | 关闭会话,返回执行过的命令数 |
| `ssh_templates` | 列出操作员在 config 里定义的命令模板 |
| `ssh_run_template` | 执行模板;参数值会被 shell 转义 |
| `ssh_read_file` | 读文本文件。`offset` / `max_bytes` / `encoding`。二进制文件会被拒绝 |
| `ssh_tail` | 读文件最后 N 行(默认 100,最多 5000)。二进制拒绝 |
| `ssh_write_file` | 写文件。`content` 与 `content_base64` 二选一;`mode` = `overwrite` \| `append` \| `create`;`backup` / `mkdir_parents` / `permissions` |
| `ssh_edit_file` | 精确字符串替换。命中 0 次报错;命中多次需 `replace_all=true` |
| `ssh_list_dir` | 列目录,返回 name/type/size/mode/uid/gid/mtime。可用 `pattern` glob |
| `ssh_stat` | 路径是否存在及元信息;符号链接会带 `symlink_target` |
| `ssh_mkdir` | 建目录,`parents` 默认 true |
| `ssh_remove` | 删文件或目录。非空目录需 `recursive=true` |
| `ssh_move` | 移动/重命名。目标已存在需 `overwrite=true` |
| `ssh_upload` | 本地 → 远端。单文件或 `recursive=true` 整个目录;`resume` / `verify`。本地侧受 `local_roots` 约束 |
| `ssh_download` | 远端 → 本地。本地侧受 `local_roots` 约束 |

几个行为细节:

- `ssh_exec` 的 `max_output_bytes` 是**每条流**各自的上限(stdout 和 stderr 分开算)。超限时继续 drain 到命令结束,所以 exit code 仍然有效,只是 `truncated=true`。
- `timeout` 必须大于 0;省略则用主机的 `command_timeout`。`max_output_bytes` / `ssh_read_file` 的 `max_bytes` 上限 8 MiB,`stdin` 上限 8 MiB——更大的内容请分块读或走 `ssh_upload`。
- `ssh_write_file` 的 `content` 只收 UTF-8 文本;图片、压缩包这类非文本内容用 `content_base64`(两者互斥,解码后上限 8 MiB)。返回里的 `content_encoding` 会告诉你是哪种。
- `ssh_batch_exec` 最多 50 台主机、最多 8 台并发;单台失败不影响其它,失败项带 `error` 字段。整体耗时是并行后的墙钟时间。
- 持久会话:`ssh_session_open` 起一个 `/bin/sh`,`cd`、`export` 等在后续 `ssh_session_send` 里保留。空闲 15 分钟自动回收,累计 500 条命令后拒绝继续,每台主机最多 4 个会话。**一条命令超时会直接销毁会话**——超时后剩下的输出无法归属,继续复用会把脏数据算到下一条命令头上。
- `ssh_tail` 从文件尾部读最多 256 KiB,按行切出最后 N 行;文件更早的内容不会加载。
- 命令模板由操作员写在 `config.json` 的 `templates` 里,AI 只能执行不能新增。参数值一律 `shlex.quote`,所以模板里**不要**自己加引号。模板可以限定 `hosts`(支持 glob)、带 `sudo` 和 `timeout`。
- 配置热加载:改完 `config.json` 最多 1 秒生效,新增/修改/删除的主机会自动断开旧连接,不影响其它主机;改坏文件时保留旧配置并在审计里记 `config.reload_failed`。`vault` 文件变化会用已派生的密钥重读(`host add` 新增的密码立即生效),但 `vault passwd` 换了 salt 之后需要重启服务。
- `ssh_upload` 的 `recursive=true` 上传整个目录:跳过符号链接(不跟随,避免逃出目录树)、尽力保留文件权限位、整棵树上限 10000 个文件,返回 `files` / `directories` / `skipped_symlinks`。
- `ssh_upload` 的 `resume=true` 从远端已有字节数继续传(远端比本地大时直接拒绝);`verify=true` 传完比对 sha256,主机没有 `sha256sum` / `shasum` 时返回 `verified=false` + `verify_error` 而不是假成功。`resume` / `verify` 只用于单文件,目录上传会拒绝。
- 超时会关闭通道(远端命令随之终止),返回 `timed_out=true`、`exit_code=null`。传输层故障只在命令**尚未发出**时重试一次;已经发出后不再重发,避免同一条命令被执行两次。
- `sudo=true` 时先探测是否需要密码。需要则走 `sudo -S -p '' -- /bin/sh -c '<整条命令>'`,
  密码只作为 stdin 首行注入,不分配 PTY(避免回显)。用 `sh -c` 包裹是为了让 sudo 覆盖整条命令,包括 `&&` 和管道。
- `ssh_move` 的 `overwrite=true` 依赖服务端的 `posix-rename` 扩展(OpenSSH 支持)。

---

## 配置

`config.json` 在 state 目录里,**不含任何秘密**。未知字段会直接报错,避免拼错被静默忽略。

```json
{
  "version": 1,
  "defaults": {
    "port": 22,
    "connect_timeout": 15.0,
    "command_timeout": 60.0,
    "keepalive": 30.0,
    "max_output_bytes": 262144,
    "host_key_policy": "tofu"
  },
  "security": {
    "local_roots": [],
    "redact_private_keys": true,
    "expose_connection_details": true
  },
  "http": {
    "host": "127.0.0.1",
    "port": 8765,
    "allowed_hosts": []
  },
  "audit": {
    "enabled": true,
    "max_bytes": 5242880,
    "keep": 5
  },
  "templates": {
    "svc-restart": {
      "command": "systemctl restart {service} && systemctl is-active {service}",
      "description": "重启服务并确认状态",
      "params": ["service"],
      "sudo": true,
      "hosts": ["prod-*"],
      "timeout": 90
    }
  },
  "hosts": {
    "prod-web": {
      "hostname": "203.0.113.10",
      "user": "deploy",
      "port": 22,
      "identity_file": "~/.ssh/id_ed25519",
      "allow_agent": true,
      "host_key_policy": "strict",
      "description": "生产 Web",
      "tags": ["prod", "web"]
    }
  }
}
```

- `defaults` 里的值会被各主机继承,主机里同名字段覆盖它。
- `security.local_roots` 为空时用进程当前工作目录。state 目录和 `~/.ssh` **始终**被禁,
  即使显式写进 `local_roots` 也不行;路径先 `resolve()` 再判断,`..` 和符号链接都逃不出去。
- `security.expose_connection_details: false` 可以让 `ssh_hosts` 只返回别名/描述/标签,
  连 host/port/user 都不给 AI。
- `host_key_policy`:`tofu` 首次连接自动固定;`strict` 必须先 `sshmcp host trust`。
- `allow_agent: false` 禁止该主机尝试 ssh-agent。`look_for_keys` 恒为 false——
  绝不会隐式捡起你没为该主机声明的 `~/.ssh` 私钥。
- `templates` 里每个模板必须有 `command` 和 `params`;`command` 里用到的占位符必须全部声明,
  声明了但没用到也会报错(防拼错)。`hosts` 留空表示所有主机可用,支持 glob。
  参数值在渲染时会被 `shlex.quote`,模板里不要再加引号。

### state 目录位置

优先级:`--home` > `$SSHMCP_HOME` > 平台默认。

| 平台 | 默认位置 |
| --- | --- |
| Windows | `%APPDATA%\sshmcp` |
| Linux | `$XDG_CONFIG_HOME/sshmcp`,否则 `~/.config/sshmcp` |
| macOS | `~/Library/Application Support/sshmcp` |

里面有 `config.json`、`vault.enc`、`known_hosts`、`audit.jsonl`、`http_token`。
`sshmcp paths` 可以打印实际路径。这些文件都已在 `.gitignore` 里,CI 也会检查它们没被提交。

### 环境变量

| 变量 | 用途 |
| --- | --- |
| `SSHMCP_HOME` | state 目录 |
| `SSHMCP_VAULT_PASSWORD_FILE` | 存主密码的文件(推荐) |
| `SSHMCP_VAULT_PASSWORD` | 主密码本身(会出现在进程环境里,不如用文件) |
| `SSHMCP_HTTP_HOST` / `SSHMCP_HTTP_PORT` | HTTP 监听地址,被同名命令行参数覆盖 |

主密码优先级:`--vault-password-file` > `SSHMCP_VAULT_PASSWORD_FILE` > `SSHMCP_VAULT_PASSWORD` > 终端提示。

---

## 凭据存储

`vault.enc` 是一个自包含的加密文件:

- **KDF**:Argon2id,64 MiB 内存、3 轮、并行度 4,随机 16 字节 salt
- **加密**:AES-256-GCM,每次保存用新的随机 nonce
- **完整性**:整个 header(含 KDF 参数)作为 AAD 参与认证,改动工作因子会导致解密失败;
  并且在派生密钥**之前**校验工作因子上下界,防止恶意 vault 文件用超大 `memory_cost` 打爆内存
- **落盘**:临时文件 + 原子替换,POSIX 上 `0600`

支持导入 OpenSSH、PKCS8、PKCS1、DER 格式的 Ed25519 / RSA / ECDSA 私钥。

改主密码:

```bash
sshmcp vault passwd     # 用新 salt 重新加密
sshmcp vault status     # 只看存了哪几类秘密,不显示值
```

### 主密码忘了怎么办

加密是认真的:**主密码丢了,vault 里的秘密就解不出来了**,没有后门。
但主机配置不在 vault 里——`config.json` 原样保留。

```bash
sshmcp vault reset
```

它会把旧文件改名为 `vault.enc.bak-<时间戳>`(不删除),然后用新主密码建一个空 vault,
并列出还需要重新添加密钥的主机。如果中途放弃输入新密码,会自动把原文件还原回去。

重置只影响存进去的密码和私钥;主机、端口、用户、已知主机密钥都不动。

---

## CLI

```
sshmcp init [--no-vault] [--force] [--clipboard]
sshmcp host add <alias> [--hostname H] [--user U] [--port P]
                        [--identity-file PATH | --private-key PATH]
                        [--password] [--passphrase] [--sudo-password]
                        [--password-clipboard] [--passphrase-clipboard]
                        [--sudo-password-clipboard] [--secrets-stdin]
                        [--description D] [--tag T] [--host-key-policy tofu|strict]
                        [--connect-timeout S] [--command-timeout S] [--no-agent|--agent]
sshmcp host list
sshmcp host show <alias>
sshmcp host remove <alias> [--keep-secrets]
sshmcp host test <alias>
sshmcp host trust <alias> [--fingerprint SHA256:...] [--yes]
sshmcp vault status | passwd [--new-clipboard] | reset [--yes]
sshmcp token show | rotate
sshmcp audit tail [-n N]
sshmcp serve [--transport stdio|http] [--http-host H] [--http-port P]
sshmcp mcp-config [--transport stdio|http]
sshmcp paths
sshmcp doctor
```

全局选项 `--home`、`--vault-password-file`、`--clipboard` 放在子命令前后都有效。

---

## 开发

```bash
pip install -e ".[dev]"
python -m pytest -q                      # 全套
python -m pytest -m protocol -q           # 只跑真实 MCP 协议端到端
```

测试不依赖外部 sshd:`tests/fakeserver.py` 用 paramiko 的 `ServerInterface` +
`SFTPServerInterface` 起一个**进程内真实 SSH 服务端**,走真协议,Windows 和 Linux 都能跑。
`tests/test_protocol_e2e.py` 更进一步,把 sshmcp 跑在子进程里,用真实 MCP 客户端走真实 JSON-RPC。

门禁里包含专门的泄露断言:命令回显密码、读含私钥的文件、异常路径、审计日志、
tool input schema、线上 payload 六处都断言无明文秘密。

设计见 [PLAN.md](PLAN.md),逐里程碑进度与踩坑记录见 [PROGRESS.md](PROGRESS.md)。

## 许可

MIT,见 [LICENSE](LICENSE)。