ssh-mcp 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +488 -143
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -628
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
package/README.md
CHANGED
|
@@ -1,204 +1,549 @@
|
|
|
1
|
-
# SSH MCP Server
|
|
1
|
+
# SSH MCP Server v2
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
|
-
[](https://nodejs.org/)
|
|
6
5
|
[](./LICENSE)
|
|
7
|
-
[](https://github.com/tufantunc/ssh-mcp/stargazers)
|
|
8
|
-
[](https://github.com/tufantunc/ssh-mcp/forks)
|
|
9
|
-
[](https://github.com/tufantunc/ssh-mcp/actions)
|
|
10
6
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
11
7
|
|
|
12
|
-
|
|
8
|
+
**SSH MCP Server** is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging.
|
|
13
9
|
|
|
14
|
-
**
|
|
10
|
+
> **WARNING — Lethal Trifecta Risk.** Giving an LLM SSH access creates a ["Lethal Trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) (private data + untrusted input + network egress). **Never run as root. Never enable `auto` approval on production.** See [SECURITY.md](./SECURITY.md) for the full threat model and mitigation checklist.
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- [Quick Start](#quick-start)
|
|
19
|
-
- [Features](#features)
|
|
20
|
-
- [Installation](#installation)
|
|
21
|
-
- [Client Setup](#client-setup)
|
|
22
|
-
- [Testing](#testing)
|
|
23
|
-
- [Disclaimer](#disclaimer)
|
|
24
|
-
- [Support](#support)
|
|
12
|
+
---
|
|
25
13
|
|
|
26
14
|
## Quick Start
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
- [Configure](#configuration) SSH MCP Server
|
|
30
|
-
- [Set up](#client-setup) your MCP Client (e.g. Claude Desktop, Cursor, etc)
|
|
31
|
-
- Execute remote shell commands on your Linux or Windows server via natural language
|
|
16
|
+
### 1. Install
|
|
32
17
|
|
|
33
|
-
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g ssh-mcp
|
|
20
|
+
```
|
|
34
21
|
|
|
35
|
-
|
|
36
|
-
- Execute shell commands on remote Linux and Windows systems
|
|
37
|
-
- Secure authentication via password or SSH key
|
|
38
|
-
- Built with TypeScript and the official MCP SDK
|
|
39
|
-
- **Configurable timeout protection** with automatic process abortion
|
|
40
|
-
- **Graceful timeout handling** - attempts to kill hanging processes before closing connections
|
|
22
|
+
### 2. Configure
|
|
41
23
|
|
|
42
|
-
|
|
24
|
+
Create `~/.config/ssh-mcp/config.toml` (Linux/macOS) or `%APPDATA%\ssh-mcp\config.toml` (Windows):
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
[defaults]
|
|
28
|
+
defaultProfile = "dev"
|
|
29
|
+
approvalMode = "ask-destructive"
|
|
30
|
+
|
|
31
|
+
[[profiles]]
|
|
32
|
+
name = "dev"
|
|
33
|
+
host = "192.168.1.100"
|
|
34
|
+
port = 22
|
|
35
|
+
user = "deploy" # NOT root!
|
|
36
|
+
auth = "key"
|
|
37
|
+
keyRef = "~/.ssh/id_ed25519"
|
|
38
|
+
role = "admin"
|
|
39
|
+
approvalPolicy = "auto" # dev is permissive
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
chmod 600 ~/.config/ssh-mcp/config.toml
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Set credentials via environment variables
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export SSH_MCP_PASSWORD="your-password" # if using auth=password
|
|
50
|
+
# OR use SSH agent (recommended):
|
|
51
|
+
export SSH_AUTH_SOCK="$SSH_AUTH_SOCK" # already set if agent running
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 4. Connect from your MCP client
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- **Parameters:**
|
|
52
|
-
- `command` (required): Shell command to execute as root using sudo
|
|
53
|
-
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
54
|
-
- **Notes:**
|
|
55
|
-
- Requires `--sudoPassword` to be set for password-protected sudo
|
|
56
|
-
- Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
|
|
57
|
-
- For persistent root access, consider using `--suPassword` instead which establishes a root shell
|
|
58
|
-
- Tool will not be available at all if server is started with `--disableSudo`
|
|
59
|
-
- **Timeout Configuration:**
|
|
60
|
-
- Timeout is configured via command line argument `--timeout` (in milliseconds)
|
|
61
|
-
- Default timeout: 60000ms (1 minute)
|
|
62
|
-
- When a command times out, the server automatically attempts to abort the running process before closing the connection
|
|
63
|
-
- **Max Command Length Configuration:**
|
|
64
|
-
- Max command characters are configured via `--maxChars`
|
|
65
|
-
- Default: `1000`
|
|
66
|
-
- No-limit mode: set `--maxChars=none` or any `<= 0` value (e.g. `--maxChars=0`)
|
|
67
|
-
|
|
68
|
-
## Installation
|
|
69
|
-
|
|
70
|
-
1. **Clone the repository:**
|
|
71
|
-
```bash
|
|
72
|
-
git clone https://github.com/tufantunc/ssh-mcp.git
|
|
73
|
-
cd ssh-mcp
|
|
74
|
-
```
|
|
75
|
-
2. **Install dependencies:**
|
|
76
|
-
```bash
|
|
77
|
-
npm install
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Client Setup
|
|
81
|
-
|
|
82
|
-
You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use this MCP Server.
|
|
83
|
-
|
|
84
|
-
**Required Parameters:**
|
|
85
|
-
- `host`: Hostname or IP of the Linux or Windows server
|
|
86
|
-
- `user`: SSH username
|
|
87
|
-
|
|
88
|
-
**Optional Parameters:**
|
|
89
|
-
- `port`: SSH port (default: 22)
|
|
90
|
-
- `password`: SSH password (or use `key` for key-based auth)
|
|
91
|
-
- `key`: Path to private SSH key
|
|
92
|
-
- `sudoPassword`: Password for sudo elevation (when executing commands with sudo)
|
|
93
|
-
- `suPassword`: Password for su elevation (when you need a persistent root shell)
|
|
94
|
-
- `timeout`: Command execution timeout in milliseconds (default: 60000ms = 1 minute)
|
|
95
|
-
- `maxChars`: Maximum allowed characters for the `command` input (default: 1000). Use `none` or `0` to disable the limit.
|
|
96
|
-
- `disableSudo`: Flag to disable the `sudo-exec` tool completely. Useful when sudo access is not needed or not available.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```commandline
|
|
56
|
+
**Claude Code:**
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add --transport stdio ssh-mcp -- ssh-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Claude Desktop / Cursor / Windsurf:**
|
|
62
|
+
```json
|
|
100
63
|
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"--",
|
|
108
|
-
"--host=1.2.3.4",
|
|
109
|
-
"--port=22",
|
|
110
|
-
"--user=root",
|
|
111
|
-
"--password=pass",
|
|
112
|
-
"--key=path/to/key",
|
|
113
|
-
"--timeout=30000",
|
|
114
|
-
"--maxChars=none"
|
|
115
|
-
]
|
|
116
|
-
}
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"ssh-mcp": {
|
|
66
|
+
"command": "ssh-mcp",
|
|
67
|
+
"env": {
|
|
68
|
+
"SSH_MCP_PASSWORD": "your-password"
|
|
69
|
+
}
|
|
117
70
|
}
|
|
71
|
+
}
|
|
118
72
|
}
|
|
119
73
|
```
|
|
120
74
|
|
|
121
|
-
|
|
75
|
+
**Never pass passwords as CLI arguments** — they're visible via `ps aux`. Use env vars, config files, SSH agent, or OS keychain.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Tools (11)
|
|
80
|
+
|
|
81
|
+
| Tool | Purpose | readOnly | destructive |
|
|
82
|
+
|------|---------|:--------:|:----------:|
|
|
83
|
+
| `list-connections` | Discover available hosts and connection status | ✅ | — |
|
|
84
|
+
| `list-sessions` | List active sessions per host | ✅ | — |
|
|
85
|
+
| `open-session` | Create a named interactive (stateful) or background session | — | — |
|
|
86
|
+
| `close-session` | Close a session, releasing resources | — | ✅ |
|
|
87
|
+
| `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
|
|
88
|
+
| `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
|
|
89
|
+
| `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
|
|
90
|
+
| `privileged-command` | Execute with sudo (always requires approval) | — | ✅ |
|
|
91
|
+
| `sftp-upload` | Upload a file via SFTP | — | ✅ |
|
|
92
|
+
| `sftp-download` | Download a file via SFTP | ✅ | — |
|
|
93
|
+
| `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
|
|
94
|
+
|
|
95
|
+
### Interactive Sessions
|
|
96
|
+
|
|
97
|
+
Sessions maintain state (CWD, environment variables) between commands:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Agent: open-session(name="deploy", type="interactive")
|
|
101
|
+
Agent: run-command(session="deploy", command="cd /opt/myapp")
|
|
102
|
+
Agent: run-command(session="deploy", command="git pull") # runs in /opt/myapp
|
|
103
|
+
Agent: run-command(session="deploy", command="npm ci") # CWD persists
|
|
104
|
+
Agent: close-session(name="deploy")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Background Sessions
|
|
108
|
+
|
|
109
|
+
Long-running processes (logs, builds):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Agent: open-session(name="logs", type="background", command="tail -f /var/log/syslog")
|
|
113
|
+
Agent: read-session-output(name="logs", lines=20) # poll
|
|
114
|
+
Agent: close-session(name="logs")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Remote host support
|
|
118
|
+
|
|
119
|
+
Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows
|
|
120
|
+
OpenSSH on Windows 11.
|
|
121
|
+
|
|
122
|
+
| | Linux / BSD / macOS | Windows OpenSSH |
|
|
123
|
+
|---|:---:|:---:|
|
|
124
|
+
| `read-command`, `run-command`, `privileged-command`, `signal-process` | ✅ | ✅ |
|
|
125
|
+
| `sftp-upload`, `sftp-download` | ✅ | ✅ |
|
|
126
|
+
| Background sessions | ✅ | ✅ |
|
|
127
|
+
| **Interactive sessions** | ✅ | ❌ |
|
|
128
|
+
|
|
129
|
+
**Interactive sessions require a POSIX shell** (sh, bash, ash, zsh). They work by
|
|
130
|
+
bracketing each command with `printf` markers and reading `$?` and `$PWD` from a
|
|
131
|
+
trailer — none of which exist in `cmd.exe`, the default shell for Windows
|
|
132
|
+
OpenSSH. Opening one against such a host fails immediately with an explicit
|
|
133
|
+
error rather than timing out; everything else works normally.
|
|
134
|
+
|
|
135
|
+
Setting PowerShell as the OpenSSH `DefaultShell` does not help: the protocol is
|
|
136
|
+
POSIX-specific, not merely non-`cmd`.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Configuration
|
|
141
|
+
|
|
142
|
+
### Profile options
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
[defaults]
|
|
146
|
+
defaultProfile = "dev"
|
|
147
|
+
sessionMaxPerConnection = 5
|
|
148
|
+
sessionIdleTimeoutMs = 600000 # 10min
|
|
149
|
+
sessionBackgroundMaxMs = 3600000 # 1hr
|
|
150
|
+
commandTimeoutMs = 60000
|
|
151
|
+
commandMaxChars = 5000
|
|
152
|
+
commandMaxOutputBytes = 1048576 # 1MB
|
|
153
|
+
connectionIdleReapMs = 900000 # 15min
|
|
154
|
+
commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
|
|
155
|
+
approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
|
|
156
|
+
approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
|
|
157
|
+
|
|
158
|
+
[[profiles]]
|
|
159
|
+
name = "prod-web-1"
|
|
160
|
+
host = "10.0.1.50"
|
|
161
|
+
port = 22
|
|
162
|
+
user = "deploy"
|
|
163
|
+
auth = "agent" # agent | key | password | keychain
|
|
164
|
+
keyRef = "~/.ssh/id_ed25519" # for auth=key
|
|
165
|
+
keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
|
|
166
|
+
via = "bastion" # ProxyJump — route through bastion profile
|
|
167
|
+
group = "prod" # Policy tier: prod | staging | dev
|
|
168
|
+
workdir = "/var/www"
|
|
169
|
+
trustedHostKey = "SHA256:..." # Pin host key (optional)
|
|
170
|
+
tty = false
|
|
171
|
+
role = "operator" # viewer | operator | admin
|
|
172
|
+
readOnly = false
|
|
173
|
+
approvalPolicy = "ask-all"
|
|
174
|
+
cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
|
|
175
|
+
sessionMaxPerConnection = 3 # per-profile override
|
|
176
|
+
sessionIdleTimeoutMs = 300000 # stricter for prod
|
|
177
|
+
commandQuotaPerDay = 200 # per-profile override
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### ProxyJump (Bastion)
|
|
181
|
+
|
|
182
|
+
Reach internal hosts behind a bastion/jump server. The `via` field specifies a profile name to tunnel through:
|
|
183
|
+
|
|
184
|
+
```toml
|
|
185
|
+
[[profiles]]
|
|
186
|
+
name = "bastion"
|
|
187
|
+
host = "bastion.example.com"
|
|
188
|
+
user = "deploy"
|
|
189
|
+
auth = "agent"
|
|
190
|
+
|
|
191
|
+
[[profiles]]
|
|
192
|
+
name = "internal-db"
|
|
193
|
+
host = "10.0.1.50" # private IP — not directly reachable
|
|
194
|
+
user = "dbadmin"
|
|
195
|
+
auth = "key"
|
|
196
|
+
keyRef = "~/.ssh/db_key"
|
|
197
|
+
via = "bastion" # tunnel through bastion
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
No agent forwarding — only a TCP tunnel via `forwardOut`. The bastion stays connected and reusable for multiple internal hosts.
|
|
201
|
+
|
|
202
|
+
### SSH CA Certificates
|
|
203
|
+
|
|
204
|
+
For enterprise setups with a central SSH Certificate Authority:
|
|
205
|
+
|
|
206
|
+
```toml
|
|
207
|
+
[[profiles]]
|
|
208
|
+
name = "prod-db"
|
|
209
|
+
host = "db.internal"
|
|
210
|
+
user = "admin"
|
|
211
|
+
auth = "key"
|
|
212
|
+
keyRef = "~/.ssh/id_ed25519"
|
|
213
|
+
cert = true # enable CA cert auth
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The certificate file is auto-detected using OpenSSH convention (`keyRef` + `-cert.pub`, e.g. `~/.ssh/id_ed25519-cert.pub`). You can override the path with `SSH_MCP_<NAME>_CERT` env var. The cert is concatenated with the private key per ssh2 convention.
|
|
217
|
+
|
|
218
|
+
### Credential Resolution Order
|
|
219
|
+
|
|
220
|
+
1. **SSH agent** (`SSH_AUTH_SOCK`) — no key material in process memory
|
|
221
|
+
2. **OS keychain** (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires `auth = "keychain"` and `@napi-rs/keyring`
|
|
222
|
+
3. **Environment variables** — `SSH_MCP_PASSWORD`, `SSH_MCP_KEY`, `SSH_MCP_SUDO_PASSWORD`, or profile-specific `SSH_MCP_<NAME>_PASSWORD`
|
|
223
|
+
4. **Key file** — `keyRef` path or `SSH_MCP_KEY` env var
|
|
224
|
+
|
|
225
|
+
**Never CLI arguments.** v2 removes `--password`, `--sudoPassword`, `--suPassword` entirely.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Policy Engine
|
|
230
|
+
|
|
231
|
+
### Roles
|
|
232
|
+
|
|
233
|
+
| Role | Dev | Staging | Prod |
|
|
234
|
+
|------|-----|---------|------|
|
|
235
|
+
| **viewer** | read-only | read-only | read-only |
|
|
236
|
+
| **operator** | read-only, safe, destructive | read-only, safe, destructive | read-only, safe |
|
|
237
|
+
| **admin** | all | all | read-only, safe, destructive |
|
|
238
|
+
|
|
239
|
+
Which column applies comes from the profile's `group`. Set it explicitly —
|
|
240
|
+
without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,
|
|
241
|
+
`local`, `test`, `sandbox`), and **an unrecognised name resolves to `prod`**,
|
|
242
|
+
the strictest tier. A production host named `web-01` is therefore treated as
|
|
243
|
+
production rather than silently getting dev permissions.
|
|
244
|
+
|
|
245
|
+
### Command Classification
|
|
246
|
+
|
|
247
|
+
Every command is classified before execution:
|
|
248
|
+
|
|
249
|
+
- **read-only**: Allowlisted commands (`ls`, `cat`, `grep`, `df`, `stat`, `systemctl status`, ...)
|
|
250
|
+
- **safe**: Non-destructive mutations (`npm install`, `git pull`, ...)
|
|
251
|
+
- **destructive**: mutations that need approval (`rm -rf /tmp/build`, ...)
|
|
252
|
+
- **privileged**: `sudo`, `su`, `doas`, `pkexec`
|
|
253
|
+
|
|
254
|
+
A separate **forbidden** list is never allowed, whatever the role or approval
|
|
255
|
+
policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
|
|
256
|
+
writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and
|
|
257
|
+
recursive `chmod 777 /` / `chown /`. Add your own patterns via the policy
|
|
258
|
+
denylist; an invalid pattern fails at startup rather than degrading silently.
|
|
259
|
+
|
|
260
|
+
### Approval Modes
|
|
261
|
+
|
|
262
|
+
- `auto` — no prompts (dev only!)
|
|
263
|
+
- `ask-destructive` — prompt for destructive/privileged (default)
|
|
264
|
+
- `ask-all` — prompt for every command
|
|
265
|
+
- `deny` — reject destructive/privileged commands outright (no prompt)
|
|
266
|
+
|
|
267
|
+
### Approval Grants (just-in-time)
|
|
122
268
|
|
|
123
|
-
|
|
269
|
+
`approvalGrantTtlMs` lets one explicit approval cover repeats of the **exact
|
|
270
|
+
same** command on the same profile for a bounded time (e.g. `300000` for five
|
|
271
|
+
minutes). It exists because approving `rm -rf /tmp/build` every few seconds
|
|
272
|
+
during an iterative task trains you to click through prompts — which is worse
|
|
273
|
+
for safety than a grant you chose deliberately.
|
|
124
274
|
|
|
125
|
-
|
|
275
|
+
A grant is bound to the exact command text, the profile and the command class:
|
|
276
|
+
approving `rm -rf /tmp/build` does not cover `rm -rf /tmp/build-prod`, the same
|
|
277
|
+
command on another host, or the same command escalated to `sudo`. Runs covered
|
|
278
|
+
by a grant appear in the audit log with `approver: "jit-grant"`, so they stay
|
|
279
|
+
distinguishable from a fresh human answer.
|
|
280
|
+
|
|
281
|
+
**Off by default** (`0` = always prompt). Auto-approval weakens the gate that
|
|
282
|
+
makes destructive commands safe, so turning it on should be a decision.
|
|
283
|
+
|
|
284
|
+
### Command Quota
|
|
285
|
+
|
|
286
|
+
`commandQuotaPerDay` bounds how many commands a profile may run in a rolling
|
|
287
|
+
24-hour window (0 = unlimited). The approval gate stops *destructive* commands
|
|
288
|
+
and the HTTP rate limiter caps request rate, but neither bounds total work — a
|
|
289
|
+
prompt-injected agent looping over allowed commands stays under both. The quota
|
|
290
|
+
is the circuit breaker for that case.
|
|
291
|
+
|
|
292
|
+
Counted after policy allows a command and before it runs, so a denied command
|
|
293
|
+
does not spend budget. The window slides rather than resetting at midnight,
|
|
294
|
+
which would let an agent spend a full quota just before the reset and another
|
|
295
|
+
immediately after.
|
|
296
|
+
|
|
297
|
+
### External Policy Engine (OPA)
|
|
298
|
+
|
|
299
|
+
For organizations that standardize on Open Policy Agent / Rego:
|
|
126
300
|
|
|
127
301
|
```bash
|
|
128
|
-
|
|
302
|
+
ssh-mcp --opaUrl=http://localhost:8181
|
|
129
303
|
```
|
|
130
304
|
|
|
131
|
-
|
|
305
|
+
When `--opaUrl` is set, every command is additionally evaluated by OPA after the built-in policy engine. The request shape follows the AuthZEN Access Evaluation contract:
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{
|
|
309
|
+
"input": {
|
|
310
|
+
"subject": { "role": "operator", "profile": "prod-web-1" },
|
|
311
|
+
"action": { "tool": "run-command", "commandClass": "destructive" },
|
|
312
|
+
"resource": { "command": "rm -rf /tmp/cache", "binary": "rm", "host": "10.0.1.50" },
|
|
313
|
+
"context": { "readOnly": false }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands (fail-open to avoid locking out access).
|
|
319
|
+
|
|
320
|
+
Example Rego policy (`ssh-mcp.rego`):
|
|
321
|
+
```rego
|
|
322
|
+
package ssh.mcp
|
|
323
|
+
|
|
324
|
+
default allow := false
|
|
325
|
+
|
|
326
|
+
# Admins can run anything on dev hosts
|
|
327
|
+
allow if {
|
|
328
|
+
input.subject.role == "admin"
|
|
329
|
+
startswith(input.subject.profile, "dev")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# Deny all destructive commands on prod
|
|
333
|
+
deny if {
|
|
334
|
+
input.action.commandClass == "destructive"
|
|
335
|
+
startswith(input.subject.profile, "prod")
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Security
|
|
342
|
+
|
|
343
|
+
### Threat Model
|
|
344
|
+
|
|
345
|
+
See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability reporting policy, and deployment checklist.
|
|
346
|
+
|
|
347
|
+
### Safe Defaults
|
|
348
|
+
|
|
349
|
+
- **Non-root** user in all examples
|
|
350
|
+
- **TOFU** host key verification (accept on first connect, verify after)
|
|
351
|
+
- **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
|
|
352
|
+
- **exec()-only** (no persistent su shells — fixes PTY leak)
|
|
353
|
+
- **Sudo via stdin** (not argv — fixes process list leak)
|
|
354
|
+
- **Sanitizer** strips CR/LF/NUL from all metadata
|
|
355
|
+
- **3-layer redaction** (field → regex → entropy) on audit logs
|
|
356
|
+
- **No CLI-arg secrets** (use env vars, keychain, or config)
|
|
357
|
+
|
|
358
|
+
### Hardening Checklist
|
|
359
|
+
|
|
360
|
+
- [ ] Create dedicated low-privilege service account on target hosts
|
|
361
|
+
- [ ] Use command-specific `sudoers` instead of `NOPASSWD: ALL`
|
|
362
|
+
- [ ] Enable `ask-all` approval for production profiles
|
|
363
|
+
- [ ] Restrict network egress on target hosts
|
|
364
|
+
- [ ] Use `readOnly = true` for monitoring profiles
|
|
365
|
+
- [ ] Review audit logs regularly
|
|
366
|
+
- [ ] Run `chmod 600 config.toml`
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Transports
|
|
371
|
+
|
|
372
|
+
### stdio (default)
|
|
373
|
+
|
|
374
|
+
For local MCP clients (Claude Code, Cursor, Windsurf). No network exposure.
|
|
132
375
|
|
|
133
|
-
**With Password Authentication:**
|
|
134
376
|
```bash
|
|
135
|
-
|
|
377
|
+
ssh-mcp # reads config from XDG path
|
|
378
|
+
ssh-mcp --config=/path/to.toml # custom config path
|
|
136
379
|
```
|
|
137
380
|
|
|
138
|
-
|
|
381
|
+
### HTTP (optional)
|
|
382
|
+
|
|
383
|
+
For remote/web clients behind a reverse proxy with TLS:
|
|
384
|
+
|
|
139
385
|
```bash
|
|
140
|
-
|
|
386
|
+
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret
|
|
387
|
+
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
|
|
141
388
|
```
|
|
142
389
|
|
|
143
|
-
|
|
390
|
+
| Flag | Default | Description |
|
|
391
|
+
|------|---------|-------------|
|
|
392
|
+
| `--bearerToken` | required | Bearer token for authentication (all routes except `GET /health`) |
|
|
393
|
+
| `--httpPort` | 3000 | HTTP listen port |
|
|
394
|
+
| `--httpHost` | 127.0.0.1 | Bind address |
|
|
395
|
+
| `--rateLimit` | 0 (off) | Max requests per minute (0 = unlimited) |
|
|
396
|
+
|
|
397
|
+
Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
|
|
398
|
+
|
|
399
|
+
When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
|
|
400
|
+
|
|
401
|
+
**Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Docker
|
|
406
|
+
|
|
144
407
|
```bash
|
|
145
|
-
|
|
408
|
+
# Build
|
|
409
|
+
docker build -t ssh-mcp .
|
|
410
|
+
|
|
411
|
+
# Run (config file + env vars for credentials)
|
|
412
|
+
docker run -i \
|
|
413
|
+
-v ./config.toml:/home/appuser/.config/ssh-mcp/config.toml:ro \
|
|
414
|
+
-e SSH_MCP_PASSWORD=secret \
|
|
415
|
+
ssh-mcp
|
|
146
416
|
```
|
|
147
417
|
|
|
148
|
-
|
|
418
|
+
Or with docker-compose:
|
|
419
|
+
|
|
149
420
|
```bash
|
|
150
|
-
|
|
421
|
+
docker-compose --profile app up
|
|
151
422
|
```
|
|
152
423
|
|
|
153
|
-
|
|
424
|
+
The Docker image runs as non-root UID 65532, with a minimal `node:22-slim` base.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## CLI Flags (v2)
|
|
429
|
+
|
|
430
|
+
Secrets are **never** passed as CLI arguments.
|
|
431
|
+
|
|
432
|
+
| Flag | Default | Description |
|
|
433
|
+
|------|---------|-------------|
|
|
434
|
+
| `--config` | XDG path | Path to TOML config file |
|
|
435
|
+
| `--host` | — | Quick start: SSH host (creates single-profile config) |
|
|
436
|
+
| `--user` | — | Quick start: SSH username |
|
|
437
|
+
| `--port` | 22 | Quick start: SSH port |
|
|
438
|
+
| `--key` | — | Quick start: Path to private key |
|
|
439
|
+
| `--workdir` | — | Quick start: Working directory for commands and sessions |
|
|
440
|
+
| `--timeout` | 60000 | Command timeout in ms |
|
|
441
|
+
| `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
|
|
442
|
+
| `--sessionMax` | 5 | Max concurrent sessions per connection |
|
|
443
|
+
| `--sessionTtl` | 600000 | Session idle timeout in ms |
|
|
444
|
+
| `--transport` | stdio | `stdio` or `http` |
|
|
445
|
+
| `--httpPort` | 3000 | HTTP transport port |
|
|
446
|
+
| `--httpHost` | 127.0.0.1 | HTTP bind address |
|
|
447
|
+
| `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
|
|
448
|
+
| `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
|
|
449
|
+
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
450
|
+
| `--insecureHostKey` | false | Disable host key verification (test only!) |
|
|
451
|
+
| `--disableApproval` | false | Skip the approval gate (quick start profile only) |
|
|
452
|
+
| `--opaUrl` | — | OPA sidecar URL for external policy |
|
|
453
|
+
| `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
|
|
454
|
+
| `--approvalGrantTtl` | 0 (off) | Auto-approve an identical command for this many ms after approval |
|
|
455
|
+
| `--auditEntropyScan` | false | Enable entropy-based secret scanning in audit |
|
|
456
|
+
| `--auditTamperEvident` | false | Enable hash-chained tamper-evident audit log |
|
|
457
|
+
| `--otelEndpoint` | — | OTLP/HTTP endpoint for OpenTelemetry traces |
|
|
458
|
+
| `--otelServiceName` | ssh-mcp | Service name reported on trace spans |
|
|
459
|
+
| `--dumpToolHashes` | — | Print SHA-256 hashes of the tool descriptions and exit |
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Migrating from v1
|
|
464
|
+
|
|
465
|
+
v2 is a breaking release. Passing a removed flag now fails at startup with the
|
|
466
|
+
replacement, rather than failing later as a confusing auth error.
|
|
154
467
|
|
|
155
|
-
|
|
468
|
+
### Tools
|
|
156
469
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
470
|
+
| v1 | v2 | Notes |
|
|
471
|
+
|----|----|-------|
|
|
472
|
+
| `exec` | `read-command` | Allowlisted read-only commands. Prefer this for reads. |
|
|
473
|
+
| `exec` | `run-command` | Arbitrary commands. Destructive ones go through the approval gate. |
|
|
474
|
+
| `sudo-exec` | `privileged-command` | Always requires approval. Password is piped via stdin. |
|
|
475
|
+
| `description` parameter | — | Removed. It was an injection vector (#44) and never reached the host. |
|
|
161
476
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
```
|
|
477
|
+
**Command results now carry status.** In v1 a failed command rejected with
|
|
478
|
+
`Error (code N)`. In v2 a non-zero exit comes back as an error result including
|
|
479
|
+
the exit code and stderr — so an empty response no longer means "it worked".
|
|
166
480
|
|
|
167
|
-
|
|
168
|
-
```bash
|
|
169
|
-
claude mcp add --transport stdio ssh-mcp --scope user -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
|
|
170
|
-
```
|
|
481
|
+
### Flags
|
|
171
482
|
|
|
483
|
+
| v1 flag | Replacement |
|
|
484
|
+
|---------|-------------|
|
|
485
|
+
| `--password` | `SSH_MCP_PASSWORD` env var (or `SSH_MCP_<PROFILE>_PASSWORD`) |
|
|
486
|
+
| `--suPassword` | `SSH_MCP_SUDO_PASSWORD` env var |
|
|
487
|
+
| `--sudoPassword` | `SSH_MCP_SUDO_PASSWORD` env var |
|
|
488
|
+
| `--disableSudo` | Use a role/policy that disallows the `privileged` class |
|
|
172
489
|
|
|
173
|
-
|
|
490
|
+
Credentials moved off the command line because CLI arguments are world-readable
|
|
491
|
+
via `/proc/<pid>/cmdline` on Linux (CWE-214). Credentials now resolve through an
|
|
492
|
+
SSH agent → OS keychain → env var → key file cascade.
|
|
174
493
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
494
|
+
### Example
|
|
495
|
+
|
|
496
|
+
```jsonc
|
|
497
|
+
// v1
|
|
498
|
+
{ "command": "npx", "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root", "--password=hunter2"] }
|
|
499
|
+
|
|
500
|
+
// v2 — credentials via env
|
|
501
|
+
{
|
|
502
|
+
"command": "npx",
|
|
503
|
+
"args": ["ssh-mcp", "--host=1.2.3.4", "--user=root"],
|
|
504
|
+
"env": { "SSH_MCP_PASSWORD": "hunter2" }
|
|
505
|
+
}
|
|
178
506
|
```
|
|
179
507
|
|
|
180
|
-
For more
|
|
508
|
+
For more than one host, move to a TOML config file (see [Configuration](#configuration))
|
|
509
|
+
and pass `--config <path>`; profiles carry per-host roles and approval policy.
|
|
510
|
+
|
|
511
|
+
### Host key verification
|
|
512
|
+
|
|
513
|
+
v1 did not verify host keys. v2 defaults to trust-on-first-use and records the
|
|
514
|
+
key; a later mismatch fails the connection. Pin explicitly with `trustedHostKey`
|
|
515
|
+
in a profile, or pass `--insecureHostKey` to opt out (test environments only).
|
|
516
|
+
|
|
517
|
+
---
|
|
181
518
|
|
|
182
519
|
## Testing
|
|
183
520
|
|
|
184
|
-
|
|
521
|
+
```bash
|
|
522
|
+
# Start test SSH server
|
|
523
|
+
docker-compose --profile test up -d
|
|
185
524
|
|
|
186
|
-
|
|
187
|
-
npm
|
|
188
|
-
```
|
|
525
|
+
# Run all tests
|
|
526
|
+
npm test
|
|
189
527
|
|
|
190
|
-
|
|
528
|
+
# Run only unit tests
|
|
529
|
+
npm test -- test/unit/
|
|
191
530
|
|
|
192
|
-
|
|
531
|
+
# Run with coverage
|
|
532
|
+
npm run coverage
|
|
533
|
+
```
|
|
193
534
|
|
|
194
|
-
##
|
|
535
|
+
## MCP Inspector
|
|
195
536
|
|
|
196
|
-
|
|
537
|
+
```bash
|
|
538
|
+
npm run inspect
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
197
542
|
|
|
198
|
-
##
|
|
543
|
+
## Contributing
|
|
199
544
|
|
|
200
|
-
|
|
545
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md). Please follow the [security checklist](./SECURITY.md#security-checklist-for-contributors) in all PRs.
|
|
201
546
|
|
|
202
547
|
## Support
|
|
203
548
|
|
|
204
|
-
If you find SSH MCP Server helpful, consider starring the repository or
|
|
549
|
+
If you find SSH MCP Server helpful, consider starring the repository or [sponsoring](https://github.com/sponsors/tufantunc)!
|