关于 SSH MCP Server
SSH MCP Server 为 AI Agent 提供受策略控制的远程主机 SSH 操作,适合需要将服务器管理接入 MCP 的团队。它通过命令分类、角色与主机组授权、审批门控和审计记录管理执行风险。
主要功能
- 01受策略控制的 SSH 命令执行
- 02角色与主机组授权
- 03破坏性操作人工审批
- 04交互式与后台会话
- 05SFTP 文件传输及审计
适用场景
- 01检查远程主机状态并读取文件
- 02在持久会话中执行部署命令
- 03经审批运行特权或破坏性命令
- 04在限定目录内传输远程文件
MCP 相关
作为 MCP 服务,向兼容客户端提供主机连接、会话、命令执行、SFTP 和配置重载等工具。命令根据策略分类和授权,且操作会记录审计信息。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
npx -y ssh-mcpSSH_MCP_PASSWORD密钥Password for the configured profile; a per-profile SSH_MCP_<PROFILE>_PASSWORD takes precedence. Credentials are never accepted as CLI arguments.SSH_MCP_KEY密钥Path to the private key file to authenticate with — the path, not the key material. A per-profile SSH_MCP_<PROFILE>_KEY takes precedence.SSH_MCP_PASSPHRASE密钥Passphrase for an encrypted private key.SSH_MCP_SUDO_PASSWORD密钥Password the sudo tool escalates with, when the policy in force allows the privileged class.本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
工具
14收录的工具列表 · 更新于 3 周前
此工具无需参数。
详细介绍
SSH MCP 服务 v2
SSH MCP 服务是一个以安全为先的模型上下文协议服务,可为大模型 Agent 提供受控的远程主机 SSH 访问,并支持命令分类、基于策略的授权、人工审批和完整的审计日志。
此服务旨在管理的风险。让大模型通过 Shell 访问远程主机,会将私人数据、不可信输入和网络出口集中在一处——这就是 Simon Willison 所说的“致命三要素”。提示词注入没有通用的解决方法,因此 ssh-mcp 会假设任何命令都可能受到攻击者影响:执行前先对命令分类,根据角色 × 主机组矩阵进行授权,对破坏性操作要求审批,并记录决策,无论结果如何。这能缩小影响范围,但无法消除风险。有两件事仍由你负责:绝不要将其指向 root 账户,并且绝不要在生产配置中将审批设为
auto。SECURITY.md中有完整的威胁模型。
快速开始
1. 安装
npm install -g ssh-mcp
2. 配置
即使没有配置,服务仍会启动,因此客户端或目录可以完成 MCP 握手并读取 tools/list——但在你完成配置之前,所有工具调用都会被拒绝,并显示一条指出下方路径的消息。在完成此步骤之前,不会在任何主机上运行任何操作。唯一的例外是 reload-config:写入文件后,调用它并设置 action: "reload"(或重启服务),即可配置已在运行的服务——请参阅重新加载。
在适用于你平台的路径创建配置文件:
| 平台 | 路径 |
|---|---|
| Linux | ~/.config/ssh-mcp/config.toml(或 $XDG_CONFIG_HOME/ssh-mcp/config.toml) |
| macOS | ~/Library/Application Support/ssh-mcp/config.toml |
| Windows | %APPDATA%\ssh-mcp\config.toml |
[defaults]
defaultProfile = "dev"
approvalMode = "ask-destructive"
[[profiles]]
name = "dev"
host = "192.168.1.100"
port = 22
user = "deploy" # NOT root!
auth = "key"
keyRef = "~/.ssh/id_ed25519"
role = "admin"
approvalPolicy = "auto" # dev is permissive
chmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.toml
配置决定此服务认可哪些主机、角色和策略规则,因此服务会检查是否只有你能读取该文件——并且会区别对待这两个平台,因为其中一个平台上的答案要明确得多。
Linux 和 macOS:强制执行。 上方命令会检查文件和目录的权限模式——这也是该命令中有 chmod 700 的原因,因为在默认 umask 下运行 mkdir -p 会使目录权限变为 0755。否则服务将拒绝启动。这里“只有所有者”含义明确,而且只需一行 chmod 命令即可修复。
Windows:根据 ACL 实际允许的操作区别处理。 Windows 没有权限模式位,因此会读取 ACL——但读取权限暴露和写入权限暴露的处理方式并不相同,因为其中一种在 Windows 上明确得多。
| ACL 允许其他账户…… | 默认处理 |
|---|---|
| 仅读取配置 | 发出提示,服务仍会启动 |
| 修改配置 | 拒绝 |
| 什么权限都没有(完全没有 ACL) | 拒绝——这意味着所有人都拥有完全控制权 |
| ……且无法读取 ACL | 拒绝,但 icacls 不存在或检查超时时除外 |
位于 %APPDATA% 下的配置会继承你、SYSTEM 和 Administrators 的访问权限,无需进行任何处理。其他位置创建的配置则不同:C:\ 下的文件会继承所有本地账户的读取权限,以及所有经过身份验证的账户的修改权限。提示消息会指出修复这两种情况所需的两条 icacls 命令。
对于读取权限暴露,服务会发出提示而不是拒绝,因为 Windows 在这方面确实比 POSIX 模糊得多;如果因此拒绝,位于文档所述位置的配置也会被阻止(#138)。对于写入权限暴露,服务会拒绝,因为这完全没有模糊之处:其他账户若能改写决定此服务认可哪些主机、角色和审批策略的文件,就构成授权绕过,而非信息泄露。
两个标志可以改变整个行为:--strictConfigAcl 会拒绝所有被检查发现的问题,包括只读授权;--allowUncheckedConfigAcl 会报告所有问题,但不会拒绝任何操作。这两种组合都不会让你无路可走,这就是 #138 带来的教训。
退出状态
| 状态 | 含义 |
|---|---|
0 | 正常关闭 |
1 | 服务存在缺陷——会打印堆栈跟踪;请报告此问题 |
2 | 调用方式或配置有误——会打印消息,不含堆栈 |
将所有非零状态都视为失败的监控程序无需更改。若监控程序通过匹配 1 来检测启动问题,则还应匹配 2。
从添加了无配置时内省功能的版本开始,未配置任何内容不再属于退出状态 2 的情况:服务会启动以便展示自身信息,并拒绝每次工具调用,reload-config 除外。如果监控程序曾使用非零退出状态来检测未配置的部署,则应监控 stderr 中的 starting unconfigured,或者在使用 HTTP 传输时从 GET /health 读取 configured。
3. 通过环境变量设置凭据
export SSH_MCP_PASSWORD="your-password" # if using auth=password
# OR use SSH agent (recommended):
export SSH_AUTH_SOCK="$SSH_AUTH_SOCK" # already set if agent running
4. 从 MCP 客户端连接
Claude Code:
claude mcp add --transport stdio ssh-mcp -- ssh-mcp
Claude Desktop / Cursor / Windsurf:
{
"mcpServers": {
"ssh-mcp": {
"command": "ssh-mcp",
"env": {
"SSH_MCP_PASSWORD": "your-password"
}
}
}
}
切勿将密码作为 CLI 参数传递——它们会通过 ps aux 显示。请使用环境变量、配置文件、SSH agent 或操作系统钥匙串。
工具(15 个)
| 工具 | 用途 | 只读 | 有破坏性 |
|---|---|---|---|
list-connections | 查找可用主机和连接状态 | ✅ | — |
list-sessions | 列出每台主机上的活动会话 | ✅ | — |
open-session | 创建命名的交互式(有状态)或后台会话 | — | — |
close-session | 关闭会话。关闭后台会话前,会先向其命令发送信号(INT/TERM/KILL),再断开其通道 | — | ✅ |
read-session-output | 读取后台会话的输出(例如 tail -f) | ✅ | — |
read-command | 执行允许列表中的只读命令(ls、cat、grep 等) | ✅ | — |
run-command | 执行任意命令(除非 approvalPolicy = "auto",否则有破坏性或需要特权的命令需经批准) | — | — |
privileged-command | 使用 sudo 执行命令(除非 approvalPolicy = "auto",否则需经批准) | — | ✅ |
sftp-upload | 通过 SFTP 上传文件(无条件替换目标文件) | — | ✅ |
sftp-download | 通过 SFTP 下载文件 | ✅ | — |
sftp-list | 列出远程目录,条目数和字节数均有限制 | ✅ | — |
sftp-upload-file | 将本地文件流式传输到远程主机,全程不经过模型上下文 | — | ✅ |
sftp-download-file | 将远程文件流式传输到本地磁盘,全程不经过模型上下文 | ✅ | — |
signal-process | 向远程 PID 发送 INT/TERM/KILL 信号 | — | ✅ |
reload-config | 显示配置文件状态,或重新读取并应用配置,无需重启(参见重新加载) | — | ✅ |
流式文件传输
sftp-upload/sftp-download 会通过模型的上下文传输文件内容:
文本在传出时是参数,在返回时是响应。
这适用于配置片段,但对于 200 MB 的 tarball 或任何二进制文件,
则完全不适用。
两者处理现有目标文件的方式也不同,现已在批准字符串中明确说明。
sftp-upload 总是无条件替换,并且每次都会写明 --overwrite,因为它始终如此;
sftp-upload-file 除非传入 overwrite: true,否则会拒绝操作,只有在这种情况下才会带上该标志。
由于 sftp-upload 将内容作为参数传入,而不是指定本地文件,
其字符串还会包含 --bytes=<n> --sha256=<32 hex>——否则,上传到同一路径的两个操作会是相同的字符串,这意味着一次批准就能覆盖两者,审计人员也无法判断实际写入的是哪组字节。
摘要使用 128 位,而不是较短的前缀,因为这项保证必须能抵御调用方自行选择两份载荷的情况,而不只是意外重复。
[policy].denylist 模式会针对整个字符串进行匹配;对于每个 SFTP 工具,还会单独针对远程路径进行匹配——路径会按给定形式、经词法规范化,并按 Windows 的读取方式处理(见“策略引擎”)。
因此,针对路径编写的规则,例如 authorized_keys$,不依赖路径在字符串中的位置或文件名的写法。
但锚定在整个字符串上的规则(^sftp:upload /root/.*$)仍然依赖这些因素:字符串布局可能由我们更改,因此应改为锚定路径。
sftp-upload-file 和 sftp-download-file 则在远程主机与本地磁盘之间直接流式传输。
字节内容及其 Base64 编码都不会到达模型——响应只包含字节数和两个路径。
这两个传输工具默认关闭,直到你配置 defaults.transferRoot 为止,
在此之前会拒绝操作并说明原因。(sftp-list 没有本地端,不需要这些配置。)
该目录是它们唯一可访问的本地位置:
[defaults]
transferRoot = "/srv/ssh-mcp-transfers"
transferMaxBytes = 268435456 # 256MB per transfer
transferTimeoutMs = 300000 # 5min with no bytes moving
每次调用都会检查;任何检查失败都会导致拒绝,而不是降低安全标准:目录必须为 0700,并且归运行此服务的账户所有;父级目录不得允许组或其他用户写入;该目录不得与 ssh-mcp 安装目录、配置目录、审计日志目录或 ~/.ssh 重叠。调用方指定的路径仅限于该目录;符号链接会被拒绝,而不会跟随;下载会先写入同级的 .part 文件,再以原子方式发布,因此传输失败时,目标位置不会留下半写入的文件。
transferTimeoutMs 是空闲时间预算,而不是总时长预算:它限制一次元数据往返所用的时间,或复制过程中没有字节传输的一段时间,并会在传输有进展时重新计时。缓慢但持续传输的大文件不会因此失败;停滞的通道仍会在一个时间窗口内失败。这就是为什么上面的字节上限不需要除以该值——总时长预算会导致 256MB 上限变成“只有链路持续达到 900 KB/s 才能传输”。
Windows 上不可用,因为目前无法验证传输根目录是否为私有目录;这两个传输工具会在 Windows 上拒绝操作,而不会写入其他账户可能有权读取的目录。
sftp-upload-file 可选接受 mode(1–511;拒绝设置 setuid、setgid 和粘滞位)。如果未指定,新建的远程文件会以 0600 发布;覆盖现有文件时,会继承被替换文件的权限位——仅继承权限位,不继承其 setuid 或 setgid。
传输操作的授权范围包括以下参数:策略引擎分类的字符串、人类读取的批准提示词,以及审计记录都会在提供时写明 --overwrite 和 --mode。因此,批准向某个路径上传一次,并不意味着也批准向同一路径进行其他上传;批准授权(approvalGrantTtlMs)也不能通过更改模式重放。
交互式会话
会话会在命令之间保留状态(CWD、环境变量):
Agent: open-session(name="deploy", type="interactive")
Agent: run-command(session="deploy", command="cd /opt/myapp")
Agent: run-command(session="deploy", command="git pull") # runs in /opt/myapp
Agent: run-command(session="deploy", command="npm ci") # CWD persists
Agent: close-session(name="deploy")
后台会话
长时间运行的进程(日志、构建):
Agent: open-session(name="logs", type="background", command="tail -f /var/log/syslog")
Agent: read-session-output(name="logs", lines=20) # poll
Agent: close-session(name="logs")
远程主机支持
已在 Linux(Debian/bash、Alpine/busybox ash)、Dropbear 和 Windows 11 上的 Windows OpenSSH 中测试。
| Linux / BSD / macOS | Windows OpenSSH | |
|---|---|---|
read-command、run-command、privileged-command、signal-process | ✅ | ✅ |
sftp-upload、sftp-download | ✅ | ✅ |
sftp-list | ✅ | ✅ |
sftp-upload-file、sftp-download-file | ✅ | ❌(本地端无法验证) |
| 后台会话 | ✅ | ✅ |
| 交互式会话 | ✅ | ❌ |
交互式会话需要 POSIX shell(sh、bash、ash、zsh)。它们通过用
printf 标记包围每条命令,并从尾随内容中读取 $? 和 $PWD 来工作——
而 cmd.exe(Windows OpenSSH 的默认 shell)中没有这些功能。对这类主机
发起交互式会话会立即失败并返回明确错误,而不是超时;其他功能都能正常工作。
将 PowerShell 设为 OpenSSH 的 DefaultShell 并无帮助:该协议专用于 POSIX,
而不只是不能用于 cmd。
配置
配置文件选项
[defaults]
defaultProfile = "dev"
sessionMaxPerConnection = 5
sessionIdleTimeoutMs = 600000 # 10min
sessionBackgroundMaxMs = 3600000 # 1hr
commandTimeoutMs = 60000
commandMaxChars = 5000 # 0 = unlimited, the config spelling of --maxChars=none
commandMaxOutputBytes = 1048576 # 1MB
connectionIdleReapMs = 900000 # 15min
commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
# transferRoot = "/srv/ssh-mcp-transfers" # enables the streaming file tools; see above
transferMaxBytes = 268435456 # 256MB per streaming transfer
transferTimeoutMs = 300000 # 5min with no bytes moving (idle, not total)
[[profiles]]
name = "prod-web-1"
host = "10.0.1.50"
port = 22
user = "deploy"
auth = "agent" # agent | key | password | keychain
keyRef = "~/.ssh/id_ed25519" # for auth=key
keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
via = "bastion" # ProxyJump — route through bastion profile
group = "prod" # Policy tier: prod | staging | dev, or your own (see [policy])
workdir = "/var/www"
trustedHostKey = "SHA256:..." # Pin host key (optional)
tty = false
role = "operator" # viewer | operator | admin
readOnly = false
approvalPolicy = "ask-all"
cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
announceAgent = true # Send AI_AGENT=ssh-mcp to this host (see below); clear it for a host you do not control
sessionMaxPerConnection = 3 # per-profile override
sessionIdleTimeoutMs = 300000 # stricter for prod
commandQuotaPerDay = 200 # per-profile override
maxChars = 2000 # per-profile override; stricter for prod
transferMaxBytes = 16777216 # per-profile override; transferRoot is not per-profile
transferTimeoutMs = 60000 # per-profile override
# Optional. Merged over the built-in role matrix; see "Policy Engine" below.
# roleBindings is keyed by role and then by tier, so the block below changes
# operator on prod and leaves operator's other tiers, and viewer and admin,
# on their defaults.
[policy]
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
[policy.roleBindings.operator]
prod = ["read-only", "safe", "destructive"]
未知的区块和键会导致启动错误,而不是警告,因此拼写错误不会让你在以为
已覆盖默认值的情况下,实际却仍使用默认值。这同样适用于角色和层级名称:
你在 [policy.roleBindings] 下写入的每个名称都必须能被某个配置文件引用,
且每个配置文件的角色和层级都必须能解析到实际绑定。启动时和每次重新加载
时都会检查这两个方向。
每个配置文件都必须有自己的 name。两个配置文件名称相同会导致启动错误,
重新加载时也会拒绝;错误会按索引指出这两个条目
(profiles[2].name repeats profiles[0].name),而不是按名称指出。此版本发布前,
这样的配置仍能启动,并会使用两个条目中的第一个。
主机端标识
此服务运行的每条命令都会通过环境变量 AI_AGENT=ssh-mcp 向主机标明自身,
这样操作员就能通过读取会话环境的主机端功能,区分 Agent 会话、人员会话
以及其他会话,例如 /etc/profile 片段或 ForceCommand 包装器。它覆盖命令
可运行的全部三个通道:一次性命令、后台会话和交互式会话。SFTP 传输不会携带
此变量,因为此服务使用的 SSH 库无法在子系统通道上发送环境请求。
只有主机选择启用时,该变量才会进入会话,启用方式是在
sshd_config 中设置 AcceptEnv AI_AGENT。没有这一行,sshd 会忽略该变量,
会话环境不会改变——在不具备 AcceptEnv 机制的 Dropbear,以及采用默认配置
的 Windows OpenSSH 上均已验证:命令照常运行,退出代码不变,只是变量不存在。
但无论主机是否启用,都会向每台主机发送此请求。 AcceptEnv 是服务端
决定存储哪些内容的策略,并不是对客户端发送内容的限制。未启用该设置的主机
(包括恶意主机)仍可从传输内容中看出,是 Agent 而非人员在操作会话。因此,
配置文件中提供了 announceAgent = false:对于你不控制的任何主机,都应将其
设为 false,这样就不会向该主机发送任何内容。默认值为 true,因为受益的
操作员正是运行你所控制主机的那些人。
有意不发送版本号。版本号会让恶意主机将目标锁定到某个特定构建,而工具名称 本身不会。(SSH 传输已经会在标识字符串中披露库的版本;对于所有使用该库的 程序来说,这都是同一个库版本。)
请将此变量视为礼貌性的标签,而非证明。任何 SSH 客户端都能设置它,任何 Agent 也都可以不设置,因此它适合用于标识你已经信任的会话,但绝不能用作授权 或入侵检测依据。权威的身份归属应以完成身份验证所用的密钥或主体为准。
ProxyJump(堡垒机)
访问堡垒机/跳板服务器后方的内部主机。via 字段指定用于建立隧道的配置名称:
[[profiles]]
name = "bastion"
host = "bastion.example.com"
user = "deploy"
auth = "agent"
[[profiles]]
name = "internal-db"
host = "10.0.1.50" # private IP — not directly reachable
user = "dbadmin"
auth = "key"
keyRef = "~/.ssh/db_key"
via = "bastion" # tunnel through bastion
不进行 Agent 转发——仅通过 forwardOut 建立 TCP 隧道。堡垒机连接会保持打开,可供多个内部主机复用。
SSH CA 证书
适用于使用中央 SSH 证书颁发机构的企业部署:
[[profiles]]
name = "prod-db"
host = "db.internal"
user = "admin"
auth = "key"
keyRef = "~/.ssh/id_ed25519"
cert = true # enable CA cert auth
证书文件会根据 OpenSSH 约定自动检测(keyRef + -cert.pub,例如 ~/.ssh/id_ed25519-cert.pub)。你可以使用 SSH_MCP_<NAME>_CERT 环境变量覆盖路径。按照 ssh2 约定,证书会与私钥拼接。
凭据解析顺序
- SSH Agent(
SSH_AUTH_SOCK)——进程内存中不存放密钥材料 - 操作系统密钥链(macOS Keychain / Windows Credential Manager / Linux Secret Service)——需要
auth = "keychain"和@napi-rs/keyring - 环境变量——
SSH_MCP_PASSWORD、SSH_MCP_KEY、SSH_MCP_SUDO_PASSWORD或配置专用的SSH_MCP_<NAME>_PASSWORD - 密钥文件——
keyRef路径或SSH_MCP_KEY环境变量
绝不使用 CLI 参数。 v2 完全移除了 --password、--sudoPassword、--suPassword。
重新加载
配置文件会在启动时读取,此后只有在你提出要求时才会再次读取:通过
reload-config 工具并设置 action: "reload",或者在除 Windows 外的所有平台上向进程发送
SIGHUP(kill -HUP <pid>)。在 Windows 上,SIGHUP 表示控制台即将关闭,因此不会触发重新加载;请使用该工具。系统不会监视此文件,因此半写入的文件不会成为生效策略。重新加载时会读取服务启动时使用的同一路径:--config 指定的路径,或所在平台的默认路径。
reload-config 接受一个参数 action:
"status"(默认值)会报告配置路径、当前生效配置的来源(file、unconfigured或flags)、加载时间、解析所依据字节内容的 sha256 值,以及上次重新加载的结果。它绝不会显示文件内容。"reload"会重新读取文件,在所有检查通过后应用配置,并报告变更内容: 添加、移除和变更的配置(按名称和字段列出)、策略规则是否变更,以及关闭了哪些连接、关闭原因和相关会话。同一份报告也会发送到 stderr。
该工具无需审批,也不消耗命令配额。调用方只能让服务重新读取操作员写入的内容;该文件即代表操作员的同意。
要么全部通过,要么全部不生效。 重新加载新文件前,会执行与启动时相同的检查,且任何更改都不会提前生效:权限检查(Linux 和 macOS 上检查模式位;Windows 上检查 ACL,并使用服务启动时指定的 --strictConfigAcl 或 --allowUncheckedConfigAcl)、TOML 解析、严格模式的架构验证、检查配置名称是否重复、角色与层级交叉校验,以及编译拒绝列表。与启动时一样,没有配置的文件无法通过架构验证,因此重新加载不会使服务处于未配置状态。另外还有两种仅在重新加载时会拒绝的情况:
defaults.transferRoot已更改或被移除。 正在进行的传输持有旧根路径,因此必须重启。原先没有transferRoot时,可以新增该设置:没有根路径就不可能有正在进行的传输。- 服务通过
--host/--user标志启动。 用默认路径中的文件替换由标志构建的配置,会在操作员未作选择的情况下改变服务的信任来源。请改用配置文件重启。
重新加载被拒绝时不会更改任何内容:配置、策略、连接、会话和审批授权都保持原样。拒绝信息会指出错误类别和路径;对于架构错误,还会列出字段路径和问题代码;绝不会引用文件中的值。
生效时机。 所有检查通过后,新配置会一次性替换旧配置,因此不会有任何工具调用看到新旧配置的混合状态。对于每个打开的连接,按配置名称处理:
| 配置文件中的变更 | 影响 |
|---|---|
role、group、approvalPolicy、readOnly、commandQuotaPerDay、maxChars、transferMaxBytes、transferTimeoutMs,或 [policy] 部分 | 连接保持打开;变更从下一条命令开始生效 |
workdir、tty、timeout、maxOutputBytes、announceAgent、sessionMaxPerConnection、sessionIdleTimeoutMs、sessionBackgroundMaxMs | 连接保持打开;新值适用于新会话和命令,报告会列出这些字段 |
host、port、user、auth、keyRef、keychainEntry、cert、via、trustedHostKey | 连接及其会话关闭;下次调用时使用新配置文件建立连接 |
| 配置文件已移除 | 连接及其会话关闭 |
通过即将关闭的堡垒机运行的连接也会关闭,依赖该连接运行的连接同样会关闭;依赖项会先于其所经过的堡垒机关闭。[defaults] 中的值会应用于所有未设置相应值的配置文件,因此更改该值也视为更改这些配置文件。defaultProfile、connectionIdleReapMs 和 approvalGrantTtlMs 会立即生效。命令配额计数会保留,因此新的 commandQuotaPerDay 会应用于已消耗的配额。首次连接时接受的主机密钥也会保留,因为连接注册表会就地更新,而不是重新构建。配置重载生效时仍在建立的新连接,仅当重载关闭其配置文件时才会被丢弃(配置文件已移除、连接字段已更改,或其堡垒机即将关闭);该调用会失败,并显示“配置在连接期间已重载;请重试调用。”其他新连接都会保留,并使用重载后的配置文件,因此重载未更改的文件不会导致其他调用的连接失败。
审批。 每次已应用的重载,即使文件未更改,也会使此前授予的所有审批失效,因此下次运行获准的命令时会再次请求审批。重载生效后才收到的审批答复会根据新规则重新检查:只有当其连接仍保持打开,且新规则在相同命令类别中仍允许或要求审批时,命令才会运行。否则会以 CONFIG_RELOADED 拒绝,且不会记录授权;请重试调用,以便根据新规则重新请求审批。对于无需提示的决策,如果重载在决策作出期间生效(例如解析连接时或 OPA 往返期间),也会按相同方式重新检查:旧规则允许的命令或复用授权,只有在连接仍保持打开,且新规则在相同类别中明确允许时才会运行;否则会以 CONFIG_RELOADED 拒绝。命令配额会根据重载后的配置文件计费。
正在运行的命令。 重载生效时正在运行的命令或传输会按启动时的规则完成,除非重载关闭其连接;下一次调用则按新规则检查。移除配置文件并重载会关闭其连接,但影响并没有听起来那么大:后台会话中的命令会依次收到 INT、TERM、KILL 信号;一次性命令或交互式会话中运行的命令则完全不会收到信号,可能会继续在主机上运行。请在配置文件仍可连接到该主机时,先用 signal-process 停止此类命令(参见 SECURITY.md)。
不会重载的内容。 命令行标志会保留进程启动时的值:传输及其 HTTP 选项、--hostKeyMode、OPA、审计和 OpenTelemetry 标志、ACL 标志,以及 --config 本身。用于替代 [defaults] 的标志(--timeout、--maxChars、--sessionMax、--sessionTtl、--commandQuota、--approvalGrantTtl)仅在尚未加载文件时生效,例如启动时。因此,启动时未配置文件的服务在重载并加载文件后,会从该文件获取默认值。
审计。 每次执行的重载都会写入一条 config:reload 记录,形式有以下三种:
| 结果 | decision | ruleId | error |
|---|---|---|---|
| 已应用 | allow | config-reload | 未设置 |
| 拒绝:检查未通过,未作任何更改 | deny | config-reload | 拒绝原因 |
| 交换开始后失败,因此可能已部分应用 | deny | config-reload-partial | 错误类别,以及可能已部分应用 |
其他字段固定如下:command 和 binary 为 config:reload,commandClass 为
safe,profile 和 user 为 -,mcpRequestId 为工具调用的请求 ID;如果是由信号触发的重新加载,则为
sighup。重新加载关闭的每个会话也会写入一条
session:close … (reload) 记录,与 close-session 工具的行为相同。无法关闭的连接只会被报告,不会回滚:新配置已经生效。
重新加载会逐个运行:如果在另一次重新加载运行期间发起请求,该请求会等待前一次完成,然后重新读取文件;在这次请求仍在等待时发起的任何请求都会加入其中,并共用其报告和记录,因此一批请求或 SIGHUP 至多会触发两次重新加载。
如果检查通过后发生故障(这是缺陷,而非拒绝),报告会说明重新加载在交换开始后失败,且可能已部分应用;请检查 reload-config 状态,如果未显示新配置,则重启。
SIGHUP 不再停止服务器。 之前,它的默认行为会结束进程,且不会刷新审计日志。现在,除 Windows 外,SIGHUP 会触发重新加载,服务器会继续运行。stdio 服务器仍会在客户端关闭 stdin 时退出。从终端启动的 HTTP 服务器也不再会在终端关闭时停止,因为它收到的挂起信号现在会被作为重新加载处理:请使用 SIGTERM 或 SIGINT 停止它,或在监督程序下运行它。
策略引擎
角色
| 角色 | 开发环境 | 预发布环境 | 生产环境 |
|---|---|---|---|
| viewer | 只读 | 只读 | 只读 |
| operator | 只读、安全、破坏性 | 只读、安全、破坏性 | 只读、安全 |
| admin | 全部 | 全部 | 只读、安全、破坏性 |
适用哪一列取决于配置文件的 group。请显式设置该字段 —
如果未设置,系统会根据配置文件名称推测层级(prod/staging/dev、
local、test、sandbox),而且 无法识别的名称会解析为 prod,
也就是最严格的层级。因此,名为 web-01 的生产主机仍会被视为生产环境,而不会在不知情的情况下获得开发权限。
请注意这对 sudo 意味着什么:admin 在 prod 上没有 privileged 权限,
因此 privileged-command 会按设计在此被拒绝——快速启动配置文件也不例外,因为它没有可供推断的名称,因此会归入 prod。如果主机不是生产环境,请明确说明:
npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
[[profiles]]
name = "build-box"
group = "dev"
配置权限矩阵
上表是默认配置,而非限制。可选的 [policy] 部分会在启动时及每次重新加载时与默认配置合并,因此,在明确标记为 prod 的主机上授予 sudo 权限,可以通过配置文件中的一行设置供人审查,而不必重新标记主机:
[policy.roleBindings.admin]
prod = ["read-only", "safe", "destructive", "privileged"]
合并会深入到角色和层级两个层面。这个区块只会更改 prod 上的
admin:staging 和 dev 上的 admin 保持默认设置,viewer
和 operator 也不受影响。默认配置中从未定义过的角色和层级会被添加,而不是被拒绝;这样,自定义 group 就能解析为实际的绑定,而不会退回到最严格的层级:
[[profiles]]
name = "build-box"
role = "admin"
group = "tier-1"
[policy.roleBindings.admin]
"tier-1" = ["read-only", "safe", "destructive"]
额外的拒绝模式也放在同一部分中,并会叠加到永不允许列表上,而非替换它:
[policy]
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
每个模式都是一个正则表达式,会针对每次调用的命令字符串进行匹配。对于五种 SFTP 工具,还会单独针对远程路径以三种形式进行匹配:原样;规范化形式,其中 // 和 /./
会折叠,.. 会解析为前一个路径段,末尾的 / 会被删除,\ 会视为 /;以及Windows 形式,匹配时不区分大小写。拒绝信息会指出匹配的是哪种形式。
Windows 下的读取结果是指 Win32 实际解析出的路径;在 Windows 11(build 26200)上通过 SFTP 测得:大小写变体、末尾的一串点和空格,以及 ::$DATA 默认数据流写法,最终都指向同一个文件。因此,某条规则能拦截一种写法却拦不住另一种,这属于绕过,而非区别对待。在规范化后的读取结果上,它会去掉这些写法,将相对于驱动器的路径(C:x/…)锚定到对应驱动器——实际解析位置取决于此服务无法获知的进程状态,而锚定是失败时拒绝的处理方式——去掉 \\?\ 和 \\.\ 设备前缀,并保留 UNC 根路径(\\server\share\… → //server/share/…)。即使规则写的是 authorized_keys,它也会以不区分大小写的方式拦截 Authorized_Keys;在 POSIX 目标上,这两者是不同文件,但该引擎在其他所有地方也都遵循宁可误拒的原则。
这些读取结果不会:
- 解析目标上的任何内容。 相对路径仍保持相对,因此以
/root/为锚点的规则无法匹配.ssh/authorized_keys;不会跟随符号链接;也不会展开 8.3 短文件名(AUTHOR~1)——任何词法读取都做不到这一点,只有查看目标自身的目录列表才能知道长文件名。 - 单独测试
sftp-upload-file或sftp-download-file的本地路径。该侧受transferRoot限制。本地路径会出现在这两个工具的命令字符串末尾,因此以字符串结尾为锚点的模式仍可在该处匹配,一如既往。
因此,请将路径规则锚定在末尾的路径段上:\.ssh/authorized_keys$ 可以匹配绝对路径、相对路径和 Windows 路径写法——也包括各自的大小写、末尾点号和 ::$DATA 变体。为命令编写的模式也会针对 SFTP 路径进行测试——^rm 会拒绝对名为 rmlist.txt 的文件执行 sftp-download。
关于性能的一点说明:模式是普通正则表达式,会针对调用方提供的路径字节运行——长度受远程路径长度限制约束,暴露面与命令字符串一直以来相同。请优先使用有锚点的线性模式;灾难性回溯的模式会卡住与之匹配的工具调用。
由于角色和层级名称是任意字符串,合并过程本身无法区分新建的自定义角色和现有角色的拼写错误。启动时会进行交叉检查,以下情况都会在启动时失败,而不是等到使用时才失败:
- 命令类别不在
read-only | safe | destructive | privileged范围内,因此priviledged这样的拼写错误不会解析成一个未授予任何权限的类别,随后在拒绝命令时又被误认为是策略判定; - 配置中任何位置出现无法识别的节或键,因此解析器不理解的配置块会报错,而不是在启动成功后悄然不执行你配置的行为;
[policy.roleBindings]下存在任何未被配置文件使用的角色或层级,因此[policy.roleBindings.operater]不会被合并为第四个角色,同时你原本想限制的配置文件却继续使用默认值运行;- 配置文件的
role没有对应绑定,或其层级在该角色下没有对应绑定,因此主机不会因无人明确记录的原因而落到read-only。
最后一条既涵盖未设置的层级,也涵盖已设置的层级。未指定 group 的配置文件仍会根据名称解析出一个层级,而这个推断出的层级也必须像其他层级一样,存在于该配置文件所属角色之下。
某个角色没有绑定的层级会授予 read-only,绝不会授予其他层级的类别。层级之间不会回退:矩阵编译在代码中时,回退到 prod 意味着回退到角色最严格的单元格;但现在 [policy] 块可以写入该单元格。
OPA 边车不是获得相同授权的替代途径。只有本地策略已允许的命令才会交由 OPA 检查,因此 OPA 可以进一步拒绝,但绝不会扩大权限。权限是否扩大,只能由这里决定。
命令分类
每条命令都会在执行前分类:
- 只读:列入允许列表的命令(
ls、cat、grep、df、stat、systemctl status等), 且仅当每个参数都能根据为该二进制文件声明的语法证明是数据时才允许——见 下文 - 安全:非破坏性变更(
npm install、git pull等) - 破坏性:需要审批的变更(
rm -rf /tmp/build等) - 特权:
sudo、su、doas、pkexec
是否授予“只读”权限由参数语法决定,而非仅由二进制文件名称决定。 系统会逐个检查允许列表中二进制文件的参数,并根据为其声明的语法进行验证
(src/policy/reader-grammar.ts);语法中未列出的选项、已列出选项的缩写、包含未列出字母的短选项组合、带有未加引号的 shell 通配符(*、?、[)的参数,或形状类似于该二进制文件写入目标的操作数,都会使整条命令降为“安全”级别。这正是 readOnly 配置文件的权限范围、viewer 在生产和预发布环境中的权限范围(在开发环境中,viewer 也拥有“安全”级别权限),以及 read-command 的要求——这些都会拒绝“安全”级别的命令,但开发环境中的 viewer 除外。因此,以前在旧的仅按名称判断规则下可以运行的命令,现在可能会被拒绝。拒绝信息会指出不接受的词(例如,`journalctl` 仅在使用其语法列出的选项和操作数时才是只读命令;此处不接受 `--foo`。),这样调用方就能知道同一命令的哪种写法仍然可用。有关此规则未能消除的残余风险,请参阅 SECURITY.md。
如果读取命令中包含 shell 元字符,也会降为“安全”级别,无论这些字符是否加引号:; & | < > ` $ ( ) { }、换行符或回车符。因为允许列表仅保证二进制文件可信,不保证 shell 会如何处理该行的其余部分。拒绝信息会指出第一个此类字符。对于 sftp-list 和 sftp-download,此字符可能位于远程路径中,因此 readOnly 配置文件会拒绝 /srv/backup(old),并显示 远程路径包含 `(`,此处将其视为 shell 元字符,因此 `sftp:list` 仅对不包含此类字符的路径开放只读权限。
另有一份独立的禁止列表,无论角色或审批策略如何,都绝不允许执行:rm -rf /、mkfs、dd of=/dev/、shutdown、curl|sh、fork 炸弹、写入 /etc/cron、/etc/systemd 或任意用户的 authorized_keys 的操作、iptables -F,以及递归执行 chmod 777 / / chown /。当上传工具的远程路径、shell 重定向的目标,或 tee、cp、mv、install 或 dd of= 的目标指向上述路径时,写入操作即为指向这些路径,且无论使用哪种写法,只要路径解析结果如上文所述,都适用此规则。两个 /etc 路径族按前缀匹配,因此 /etc/crontab 和 /etc/cron.d 下的所有内容都在覆盖范围内;仅以该目录名称开头的相邻路径(例如 /etc/cronography)也会被拒绝——这属于失败即关闭的策略。以其他方式表示的写入操作(sed -i、rsync、cmd.exe 的 copy、move 和 robocopy、交给解释器执行的程序、由目标程序计算出的路径)不在此列表中;它们由分类和审批策略管理,运维人员的拒绝列表也可以禁止这些操作。可通过策略拒绝列表添加自定义模式;如果模式无效,程序会在启动时失败,不会静默降级。
审批模式
auto— 不提示(仅限开发环境!)ask-destructive— 对破坏性/特权操作进行提示(默认)。实际范围比听起来窄: 除了永不允许的列表外,destructive包括一种rm -rf /path模式、带有 写入/执行标志的find、无法解析的命令词、交给本服务无法读取的解释器执行的程序(python3 -c、perl -e、node -e、通过管道传入的程序 — 但不包括awk,因为它的程序不会被读取),以及sftp-upload/交互式open-session— 权限提升会将其归类为privileged,同样会触发提示。普通写入、服务控制和信号操作不会触发提示。在生产环境依赖此模式前,请参阅 SECURITY.md。ask-all— 对每条命令都进行提示deny— 直接拒绝破坏性/特权命令(不提示)
审批授权(即时生效)
approvalGrantTtlMs 允许一次明确审批在限定时间内,对同一配置上的完全相同
命令的重复执行生效(例如,300000 表示五分钟)。设置此项是因为在迭代任务中每隔几秒就要审批一次 rm -rf /tmp/build,会让你养成直接点过提示的习惯 — 这比有意授予授权更不安全。
授权与确切的命令文本、配置和命令类别绑定:审批 rm -rf /tmp/build 不会涵盖 rm -rf /tmp/build-prod、在另一台主机上执行相同的命令,或通过 sudo 提权后执行相同的命令。由授权覆盖的运行会在审计日志中显示 approver: "jit-grant",因此可以与新的人为答复区分开来。
默认关闭(0 = 始终提示)。自动审批会削弱保障破坏性命令安全的关卡,因此启用它应当经过慎重考虑。
回答提示
审批通过 MCP elicitation 请求进行,因此你看到的内容取决于客户端显示的对话框。接受即表示批准该命令 — 无需填写第二个字段。
你有 10 分钟作答。超时后,请求会过期,命令将被拒绝,而不是继续等待;拒绝信息会说明这一点。如果提示可能仍在客户端中打开,请等准备好后重新运行命令。如果客户端完全不支持 elicitation,所有破坏性和特权命令都会被拒绝,并显示 APPROVAL_UNAVAILABLE 及相应原因 — 按设计,审批采用默认拒绝的安全策略。
命令配额
commandQuotaPerDay 限制配置在滚动 24 小时窗口内可运行的命令数量(0 = 不限)。审批关卡会阻止破坏性命令,HTTP 速率限制器会限制请求速率,但二者都不限制总工作量 — 被提示词注入的 Agent 若循环执行允许的命令,仍不会触及这两项限制。配额就是为这种情况设置的断路器。
命令通过策略检查后、开始运行前计入配额,因此被拒绝的命令不会消耗配额。时间窗口会向前滚动,而不是在午夜重置,否则 Agent 可以在重置前用完全部配额,随后立即再用一次。
外部策略引擎(OPA)
适用于采用 Open Policy Agent / Rego 的组织:
ssh-mcp --opaUrl=http://localhost:8181
设置 --opaUrl 后,内置引擎允许的命令还会交由 OPA 评估。OPA 只能收紧权限。 如果内置引擎已拒绝某条命令,则会直接返回该拒绝结果,完全不会调用 OPA。因此,即使边车返回 allow,也无法授予角色绑定所禁止的权限类别。若要放宽权限,请编辑 [policy]。
服务中断时会回退到本地决策,并且每分钟记录一条警告。这是默认行为,因为 OPA 是一个额外的拒绝层,停止所有工作会造成更严重的故障。不过,如果运维人员将 OPA 部署为授权关卡,那么服务中断期间该关卡就会失效,而唯一的信号是一条 MCP 客户端通常会丢弃的 stderr 日志。--opaFailClosed 会在关卡不可用时一律拒绝;拒绝结果会带有 ruleId: "opa-unavailable",这样审计记录就能表明关卡不可用,而不是误以为是策略拒绝了该命令。
请求格式参考了 AuthZEN Access Evaluation 契约:
{
"input": {
"subject": { "role": "operator", "profile": "prod-web-1" },
"action": { "tool": "run-command", "commandClass": "destructive" },
"resource": { "command": "rm -rf /tmp/cache", "binary": "rm", "host": "10.0.1.50" },
"context": { "readOnly": false }
}
}
对于五个 SFTP 工具,resource 还包含三个路径键:remotePath,即给定的路径;以及 remotePathNormalized 和 remotePathWindows,分别对应本地拒绝列表测试的两种路径形式。这样,规则无需解析 command,也无需在 Rego 中重新实现这两种路径形式,即可匹配规范化路径。两个上传工具还会添加 remoteWrite: true,表示本地策略会拒绝永远不允许写入的路径;因此,规则也无需解析 command,即可限制写入。其他工具不会提供这四个键,路径为空时也不会提供。remotePath 保留原样给定的路径——/root//.ssh/x 会以这种形式传入,因此对目录使用 startswith 规则会匹配失败;请匹配末尾路径段,或匹配规范化键。remotePathWindows 保留给定路径的大小写;若要进行不区分大小写的匹配,请在策略中对两侧都使用 lower()。请匹配具体字段,而不是整个 resource 对象,因为不同工具的键各不相同。
OPA 会返回 { "result": true/false }。如果 OPA 拒绝(result: false),即使内置引擎允许该命令,也会阻止执行。如果 OPA 无法访问,默认采用内置引擎的决策(失败开放,以避免访问被完全锁定);设置 --opaFailClosed 则会改为拒绝。若 HTTP 状态为 200,但没有布尔类型的 result,则视为 OPA 无法访问——对于未定义的文档,OPA 就会这样响应;因此,包名称错误或未激活的 bundle 会被视为服务中断,而不是许可。
Rego 策略示例(ssh-mcp.rego):
package ssh.mcp
default allow := false
# Admins pass the OPA gate on dev hosts. The built-in policy still applies on
# top: this widens nothing that the role bindings withhold.
allow if {
input.subject.role == "admin"
startswith(input.subject.profile, "dev")
}
# Deny all destructive commands on prod
deny if {
input.action.commandClass == "destructive"
startswith(input.subject.profile, "prod")
}
安全
威胁模型
完整的威胁模型、漏洞报告政策和部署清单,请参阅 SECURITY.md。
供应链
发布版本附带签名证明,这些证明通过 Sigstore 发布, 并记录在其公开透明日志中。它们存储在两个不同的存储库中,这决定了各自的验证方式:
| 证明 | 谓词 | 存储位置 | 起始版本 |
|---|---|---|---|
| 构建来源证明 — SLSA 构建级别 2 | slsa.dev/provenance/v1 | npm | 每个发布版本 |
| SBOM — CycloneDX 和 SPDX | cyclonedx.org/bom、spdx.dev/Document | GitHub | v2.4.0 之后的发布版本 |
来源证明来自 npm 可信发布: 发布工作流使用短期 OIDC 令牌进行身份验证,且不存储凭据, 因此没有长期 npm 令牌可供泄露。
npm audit signatures # provenance, against an installed tree
npm pack ssh-mcp # the SBOM attestation is bound to the tarball, so fetch it
gh attestation verify ssh-mcp-*.tgz --repo tufantunc/ssh-mcp --predicate-type https://cyclonedx.org/bom
最后一条命令中的两个标志都至关重要。gh attestation verify 默认使用
SLSA 谓词,因此如果不指定 --predicate-type,它会过滤掉 SBOM 并报告未找到任何内容;
而它原本要查找的来源证明存储在 npm 中,并非 --repo 所查询的 GitHub
存储库。对于 SPDX 证明,请使用 https://spdx.dev/Document。
这两份 SBOM 也附在每个 GitHub 发布版本中,供阅读而非验证。
级别 2,而非 3。 来源证明由通用 GitHub 托管运行器签名 —
builder.id 为 https://github.com/actions/runner/github-hosted — 构建本身可以影响该运行器;构建级别 3 要求使用构建本身无法影响的隔离构建器。目前,达到级别 3
与可信发布不兼容:npm 在该设置保持默认值时会自动启用自己的来源证明,
并忽略任何外部生成的证明。因此,如今达到级别 3 就意味着改回长期 npm 令牌 —
以牺牲上文所述的特性换取一个级别数字。
安全默认设置
- 非 root 用户用于所有示例
- TOFU 主机密钥验证(首次连接时接受,之后验证 — 在单个进程内有效;请参阅 SECURITY.md)
- RFC 9142 算法允许列表(不使用 SHA-1、CBC 或 ssh-rsa)
- 仅使用 exec() 进行提权(
privileged-command不接受session,因此 sudo 不会在持久化 shell 中运行 — 修复 PTY 泄漏。普通命令支持交互式会话;此项针对的是提权,而非要求所有路径都使用 exec 通道) - 通过 stdin 使用 Sudo(不通过 argv — 修复进程列表泄漏)
- 清理器会从所有元数据中移除 CR/LF/NUL
- 3 层脱敏(字段 → 正则表达式 → 熵)适用于审计日志
- 不通过 CLI 参数传递机密(使用环境变量、密钥链或配置)
- Agent 声明是公开的,而非隐藏的 — 每条命令都会向主机发送
AI_AGENT=ssh-mcp,包括从未选择存储该信息的主机;对于指向非自有主机的配置文件,请清除announceAgent(请参阅主机端标识)
加固检查清单
- 在目标主机上创建权限较低的专用服务账户
- 使用针对具体命令的
sudoers,而非NOPASSWD: ALL - 为生产配置文件启用
ask-all审批 - 限制目标主机的网络出口流量
- 对监控配置文件使用
readOnly = true - 定期检查审计日志
- 运行
chmod 700 <config dir> && chmod 600 config.toml(Windows:%APPDATA%下的 ACL 已受到限制)
传输方式
stdio(默认)
适用于本地 MCP 客户端(Claude Code、Cursor、Windsurf)。不会暴露网络。
ssh-mcp # reads config from XDG path
ssh-mcp --config=/path/to.toml # custom config path
HTTP(可选)
适用于位于反向代理(带 TLS)后面的远程/Web 客户端:
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
| Flag | 默认值 | 描述 |
|---|---|---|
--bearerToken | 必填 | 用于身份验证的 Bearer 令牌(除 GET /health 外的所有路由) |
--httpPort | 3000 | HTTP 监听端口 |
--httpHost | 127.0.0.1 | 绑定地址 |
--rateLimit | 0(关闭) | 每个客户端每分钟最多允许的已验证请求数(0 = 不限) |
--httpSessionTtl | 1800000 | MCP 客户端 HTTP 会话的空闲超时时间,单位为 ms(正在处理请求或打开 SSE 流的会话不会过期)。达到 64 个会话上限时,新客户端会逐出最近最少活跃且没有正在处理请求的会话;只有在没有其他可逐出的会话时,才会逐出持有 SSE 流的会话 |
--authFailureLimit | 10 | 每个客户端每分钟允许的 Bearer 身份验证失败次数(0 = 关闭) |
--trustProxy | false | 从 X-Forwarded-For 读取客户端地址,但仅当对端是代理时才读取;单独使用时表示对端为环回地址 |
--trustedProxies | — | 允许发送 X-Forwarded-For 的对端地址,以逗号分隔。为空时仅信任环回地址 |
端点:POST /(MCP Streamable HTTP)、GET /status、GET /health
GET /health 返回 {"healthy": true, "configured": <bool>}。无论哪种情况,它都会返回 200——
healthy 表示服务存活;如果没有设置配置文件,configured 则为 false。这种情况可能是配置绑定挂载未能生效且未发出提示:服务会绑定端口,但会拒绝除 reload-config 以外的所有工具调用。重新加载文件后,configured 会变为 true。GET /status 包含配置文件列表本身,并且需要 Bearer 令牌才能访问。
--rateLimit 是按客户端计算的请求额度,客户端的识别方式与下文的身份验证失败额度相同。每个已验证请求都会消耗额度:POST / 和 MCP 路由的其余部分、GET /status,以及对不存在路径的已验证请求。GET /health 不消耗额度。错误令牌也不会消耗额度,因此未经身份验证的流量不会占用正常客户端的额度。客户端额度用尽时,服务会返回 HTTP 429 和 JSON-RPC 错误响应体;Retry-After 会给出该客户端下一次请求获准的等待秒数。
身份验证失败会单独限流,且默认启用。--rateLimit 从未处理错误的 Bearer 令牌,因为身份验证检查会先于限流器作出响应——因此令牌猜测可以以网络速度进行。--authFailureLimit 为每个客户端设置独立的小额额度,只有收到 401 时才会消耗;正确的令牌不会消耗该额度,因此正常客户端不会限制自身。一旦某个地址用尽额度,它发出的每个请求都会被限制,包括使用正确令牌的请求——这是有意为之,否则响应猜测请求就会泄露调用方猜中的令牌。对于此额度和 --rateLimit,客户端都通过套接字地址区分。在反向代理之后,这意味着所有客户端共用各自的一份额度,因此如果代理由你管理,请设置 --trustProxy——如果未设置此选项而服务首次看到 X-Forwarded-For,就会打印警告。--trustProxy 会使用 X-Forwarded-For 中最右侧的条目,也就是代理自身追加的跳点;其左侧的内容都来自客户端,因此读取最左侧条目会让客户端自行选择额度,或消耗受害者的额度。但这只有在代理确实追加了该条目的情况下才成立,因此只有当对端是代理时才会读取此标头——单独使用 --trustProxy 表示对端为环回地址,适用于上述部署;如果代理位于其他位置,请使用 --trustedProxies 指定代理。若无法将标头读取为地址,或对端不受信任,服务会退回使用套接字地址并提示一次,避免选项未生效却不作提示。默认假设只有一个受信任的跳点。格式错误的 --authFailureLimit 会导致启动被拒绝,而不会静默禁用检查;只有设为 0 才会关闭检查。
IPv6 客户端按其 /64 而非完整地址区分,这适用于这两种配额。/64 通常是分配给订阅者的最小网段,因此,共用一个 /64 的主机——例如家庭或办公室网络中的主机,或托管服务提供商分配到同一 /64 的客户——共用同一配额,就像位于同一 IPv4 NAT 后的主机一样。携带 IPv4 地址的 IPv6 地址(IPv4 映射地址,或 NAT64 前缀 64:ff9b::/96)会计为对应的 IPv4 客户端。使用自有前缀的 NAT64 或 SIIT 转换器无法识别:它转换的每个 IPv4 客户端都会共用该前缀的 /64,因此共用一个配额。
始终在反向代理(Caddy/nginx)处终止 TLS。服务器仅监听 127.0.0.1。
Docker
# Build
docker build -t ssh-mcp .
# Run (config file + env vars for credentials)
docker run -i \
-v ./config.toml:/home/appuser/.config/ssh-mcp/config.toml:ro \
-e SSH_MCP_PASSWORD=secret \
ssh-mcp
也可以使用 docker-compose:
docker-compose --profile app up
Docker 镜像以非 root UID 65532 运行,并使用精简的 node:22-slim 基础镜像。
CLI 标志(v2)
绝不会将密钥作为 CLI 参数传递。
| 标志 | 默认值 | 描述 |
|---|---|---|
--config | platform config dir (see Configure) | TOML 配置文件的路径 |
--host | — | 快速启动:SSH 主机(创建单配置文件配置) |
--user | — | 快速启动:SSH 用户名 |
--port | 22 | 快速启动:SSH 端口 |
--key | — | 快速启动:私钥路径 |
--workdir | — | 快速启动:命令和会话的工作目录 |
--group | prod | 快速启动:策略层级——prod、staging 或 dev |
--timeout | 60000 | 命令超时时间,单位为 ms |
--maxChars | 5000 | 命令最大长度(none 或 0 可禁用限制;在配置文件中,相同设置为 commandMaxChars = 0) |
--sessionMax | 5 | 每个连接的最大并发会话数 |
--sessionTtl | 600000 | SSH 会话的空闲超时时间,单位为 ms |
--transport | stdio | stdio 或 http |
--httpPort | 3000 | HTTP 传输端口 |
--httpHost | 127.0.0.1 | HTTP 绑定地址 |
--bearerToken | — | HTTP 传输身份验证用的 Bearer 令牌(--transport=http 时必需) |
--rateLimit | 0 | 每个客户端每分钟允许的最大已认证请求数(0 = 不限) |
--httpSessionTtl | 1800000 | MCP 客户端 HTTP 会话的空闲超时时间,单位为 ms(有请求正在处理或 SSE 流处于打开状态的会话不会过期)。达到 64 个会话上限时,新客户端会逐出最近最少活动且没有正在处理请求的会话;仅当没有其他可逐出的会话时,才会逐出持有 SSE 流的会话 |
--authFailureLimit | 10 | 每个客户端每分钟允许的 Bearer 身份验证失败次数(0 = 关闭) |
--trustProxy | false | 从 X-Forwarded-For 读取客户端地址,但仅当对端是代理时才读取——未指定时要求对端为环回地址 |
--trustedProxies | — | 允许发送 X-Forwarded-For 的对端地址,以逗号分隔。为空时仅允许环回地址 |
--allowedHosts | bind address + localhost | DNS 重绑定防护接受的 Host 标头,以逗号分隔 |
--hostKeyMode | tofu | tofu | strict | insecure。strict 仅接受通过 trustedHostKey 固定的主机。请参阅 SECURITY.md |
--insecureHostKey | false | 对没有 trustedHostKey 的主机禁用主机密钥验证——已固定的密钥仍会拒绝连接(仅用于测试!) |
--allowUncheckedConfigAcl | false | Windows:报告所有 ACL 检查结果,但不拒绝任何内容 |
--strictConfigAcl | false | Windows:对所有 ACL 检查结果一律拒绝,包括只读权限过度授予 |
--disableApproval | false | 跳过审批门控(仅适用于快速启动配置文件) |
--opaUrl | — | 外部策略的 OPA sidecar URL |
--opaFailClosed | false | OPA 不可访问时拒绝所有命令,而不是回退到本地策略 |
--opaTimeoutMs | 10000 | 等待 OPA sidecar 的时长。值越低,触发 fail-open 的代价越小;值越高,越晚察觉故障 |
--commandQuota | 0 (off) | 每个配置文件在滚动 24 小时内允许的最大命令数 |
--approvalGrantTtl | 0 (off) | 审批后,在指定毫秒数内自动批准相同命令 |
--auditEntropyScan | false | 在审计中启用基于熵的密钥扫描 |
--auditTamperEvident | false | 启用哈希链防篡改审计日志 |
--otelEndpoint | — | OpenTelemetry 跟踪的 OTLP/HTTP 端点 |
--otelServiceName | ssh-mcp | 跟踪 span 中报告的服务名称 |
--dumpToolHashes | — | 输出工具描述的 SHA-256 哈希并退出 |
从 v1 迁移
v2 是一个包含破坏性变更的版本。现在,传入已移除的标志会在启动时失败,并提示替代项,而不会等到之后才以令人困惑的身份验证错误失败。
工具
| v1 | v2 | 说明 |
|---|---|---|
exec | read-command | 允许列表中的只读命令。读取时优先使用此项。 |
exec | run-command | 任意命令。破坏性命令和特权命令需通过审批流程,除非 approvalPolicy = "auto"。 |
sudo-exec | privileged-command | 除非 approvalPolicy = "auto",否则需要审批。密码通过 stdin 管道传入。 |
description parameter | — | 已移除。这是一个注入向量(#44),且从未传到主机。 |
命令结果现在会附带状态。 在 v1 中,失败的命令会以 Error (code N) 拒绝。在 v2 中,非零退出码会作为错误结果返回,其中包含退出码和 stderr,因此空响应不再意味着“命令成功”。
标志
| v1 标志 | 替代项 |
|---|---|
--password | SSH_MCP_PASSWORD 环境变量(或 SSH_MCP_<PROFILE>_PASSWORD) |
--suPassword | SSH_MCP_SUDO_PASSWORD 环境变量 |
--sudoPassword | SSH_MCP_SUDO_PASSWORD 环境变量 |
--disableSudo | 使用不允许 privileged 类的角色/策略 |
凭据已从命令行移除,因为在 Linux 上可通过 /proc/<pid>/cmdline 读取 CLI 参数(CWE-214)。现在凭据会依次通过 SSH agent → OS 密钥链 → 环境变量 → 密钥文件进行解析。
示例
// v1
{ "command": "npx", "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root", "--password=hunter2"] }
// v2 — credentials via env
{
"command": "npx",
"args": ["ssh-mcp", "--host=1.2.3.4", "--user=root"],
"env": { "SSH_MCP_PASSWORD": "hunter2" }
}
如果要连接多个主机,请改用 TOML 配置文件(参见配置),并传入 --config <path>;配置文件中的配置档包含每台主机的角色和审批策略。
主机密钥验证
v1 不会验证主机密钥。v2 默认采用首次使用时信任(trust-on-first-use),并将密钥记录在内存中,直到进程结束;同一进程中后续密钥不匹配时,连接会失败。密钥不会写入磁盘,也不会读取 ~/.ssh/known_hosts,因此重启后会重新接受密钥——参见
SECURITY.md。在配置档中通过 trustedHostKey 显式固定密钥,这是此处唯一能在重启后保留的控制项——且任何主机密钥模式都不会覆盖它,因此 --insecureHostKey 仅对尚未固定密钥的主机有效(仅用于测试环境)。
测试
# Start test SSH servers. --build matters: compose reuses an existing image, and a
# fixture change (such as the alpine target's AcceptEnv line) then never lands,
# which shows up as a product failure in the integration suite.
docker compose --profile test up -d --build
# Run all tests
npm test
# Run only unit tests
npm test -- test/unit/
# Run with coverage
npm run coverage
MCP Inspector
npm run inspect
贡献
请参阅 CONTRIBUTING.md。请在所有 PR 中遵循安全检查清单。
支持
如果你觉得 SSH MCP Server 有用,请考虑为仓库加星,或赞助!
收录于
也收录于官方 MCP 注册表,条目为 io.github.tufantunc/ssh-mcp。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。