@akms/mcp-ssh 0.1.4 โ 0.1.6
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 +282 -282
- package/lib/cli.cjs +41 -14
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +41 -14
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +13 -2
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +2 -2
- package/lib/index.d.ts +2 -2
- package/lib/index.js +13 -2
- package/lib/index.js.map +1 -1
- package/package.json +69 -69
package/README.md
CHANGED
|
@@ -1,282 +1,282 @@
|
|
|
1
|
-
# @akms/mcp-ssh
|
|
2
|
-
|
|
3
|
-
> ๐ Internal use only โ built for our organization's services.
|
|
4
|
-
|
|
5
|
-
MCP server that lets an AI agent run shell commands and transfer files on a remote host over SSH.
|
|
6
|
-
|
|
7
|
-
The host is **pre-registered** in the server's own environment; the agent never receives a hostname, key or password as an argument, and no tool takes one. Every command and path is screened against the guard policy before anything reaches the network.
|
|
8
|
-
|
|
9
|
-
## ๐ฆ Installation
|
|
10
|
-
|
|
11
|
-
Requires Node.js 18 or later.
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install -g @akms/mcp-ssh # installs the `akms-mcp-ssh` command
|
|
15
|
-
npx -y @akms/mcp-ssh --help # or run it straight from the registry
|
|
16
|
-
akms-mcp-ssh --version # show the installed version
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## โ๏ธ MCP client setup
|
|
20
|
-
|
|
21
|
-
**Let the CLI write it.** `
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
npx -y @akms/mcp-ssh
|
|
25
|
-
npx -y @akms/mcp-ssh
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
Claude Code (user scope) /home/me/.claude.json
|
|
32
|
-
|
|
33
|
-
? 1 SSH host โ pick it to manage it
|
|
34
|
-
โฏ ssh-prod deploy@10.0.0.5:22 key ยท readonly, pinned
|
|
35
|
-
โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
36
|
-
Add a host
|
|
37
|
-
Switch client also found: Codex CLI
|
|
38
|
-
Quit
|
|
39
|
-
โโ move ยท enter select ยท esc back
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
The hosts are the menu: pick one to edit, check, print or remove it. Adding or editing a host opens a settings form โ every setting listed with its value, picked and changed in any order, with **Test the connection** and **Save** below. Choose a private key from the keys found in `~/.ssh`, and a route: directly, through a jump host, through a proxy command, or through both.
|
|
43
|
-
|
|
44
|
-
On an interactive terminal:
|
|
45
|
-
|
|
46
|
-
| Keys | Action |
|
|
47
|
-
|---|---|
|
|
48
|
-
| โ / โ, Enter | Move through a menu and select a setting or action |
|
|
49
|
-
| Esc | Go back one level; inside a field or choice, return to the form with that setting unchanged |
|
|
50
|
-
| โ / โ, Home / End | Move the cursor within text being entered |
|
|
51
|
-
| Backspace / Delete, Ctrl+U | Delete characters or clear the current input line |
|
|
52
|
-
| Enter with no input | Keep the displayed value; enter `-` to clear an optional value |
|
|
53
|
-
| Ctrl+C / Ctrl+D | Exit
|
|
54
|
-
|
|
55
|
-
Esc also undoes a choice when pressed in its follow-up question: choosing a jump route and then pressing Esc at the jump-host prompt restores the previous route. Esc at the **Write it?** preview reopens the form with the entered values. Leaving a changed form with Cancel or Esc asks whether to discard changes.
|
|
56
|
-
|
|
57
|
-
Without an interactive terminal (a pipe, or Git Bash's mintty without winpty), the same menus use numbered choices; enter a number, key or label followed by Enter. After saving, restart the MCP client or reconnect the server from its MCP menu to load the updated entry.
|
|
58
|
-
|
|
59
|
-
| Client | Config file | How it is written |
|
|
60
|
-
|---|---|---|
|
|
61
|
-
| Claude Code | `~/.claude.json` (user scope) | through `claude mcp add-json โฆ -s user`, never by editing the file |
|
|
62
|
-
| Codex | `~/.codex/config.toml` | only the `[mcp_servers.<name>]` tables are rewritten; keys you added to them stay |
|
|
63
|
-
| Claude Desktop | `claude_desktop_config.json` | `mcpServers.<name>` is set, every other key kept |
|
|
64
|
-
| Cursor | `~/.cursor/mcp.json` | same |
|
|
65
|
-
|
|
66
|
-
Only entries that launch this package are listed or touched. An edit keeps every `SSH_*` variable the wizard does not ask about (timeouts, deny patterns, `SSH_CWD`, โฆ). **Print** shows an entry as JSON, as a Codex TOML table and as a `claude mcp add-json` line, for when pasting is easier. On Windows the entry launches through `cmd /c npx`, the form Claude Code expects for npx servers there.
|
|
67
|
-
|
|
68
|
-
**Or by hand โ one server entry per host** โ plain `SSH_*` variables, no config file, no JSON inside JSON. Claude Code reads `.mcp.json` in the project or `~/.claude.json` globally; Claude Desktop takes the same block inside `claude_desktop_config.json`:
|
|
69
|
-
|
|
70
|
-
```json
|
|
71
|
-
{
|
|
72
|
-
"mcpServers": {
|
|
73
|
-
"ssh-prod": {
|
|
74
|
-
"command": "npx",
|
|
75
|
-
"args": ["-y", "@akms/mcp-ssh"],
|
|
76
|
-
"env": {
|
|
77
|
-
"SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_KEY": "~/.ssh/id_ed25519",
|
|
78
|
-
"SSH_READONLY": "true", "SSH_ALLOWED_PATHS": "/var/log,/opt/app"
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
`SSH_HOST` + `SSH_USER` is the whole minimum, and the **private key stays a file on disk** โ only its path goes in the config. Because each server fronts one host, the agent never names it: `ssh_exec({ command: "df -h" })` is a complete call. The server name (`ssh-prod`) is what the agent sees in its tool list, so name it after the host.
|
|
86
|
-
|
|
87
|
-
The server speaks MCP over **stdio**: stdout carries the JSON-RPC stream and all logging goes to stderr. Running it by hand in a terminal only prints the startup line and waits.
|
|
88
|
-
|
|
89
|
-
## ๐๏ธ Host configuration
|
|
90
|
-
|
|
91
|
-
Every setting is an environment variable in this server's own entry. There is no config file and no JSON โ which is also why "what is this server allowed to do" is answerable by reading the entry above, with no second place to check.
|
|
92
|
-
|
|
93
|
-
### Connection
|
|
94
|
-
|
|
95
|
-
| `SSH_*` variable | Description |
|
|
96
|
-
|---|---|
|
|
97
|
-
| `SSH_HOST` โ
| Hostname or IP |
|
|
98
|
-
| `SSH_USER` โ
| Login user |
|
|
99
|
-
| `SSH_PORT` | Default `22` |
|
|
100
|
-
| `SSH_KEY` | **Path** to a private key file, `~` expanded |
|
|
101
|
-
| `SSH_PASSPHRASE` | Key passphrase |
|
|
102
|
-
| `SSH_PASSWORD` | Password auth, when no key is used |
|
|
103
|
-
| `SSH_HOST_KEY` | Pin the server's host key, `SHA256:โฆ`; a mismatch aborts the connection |
|
|
104
|
-
| `SSH_NAME` | Alias shown to the agent; defaults to the hostname |
|
|
105
|
-
| `SSH_DESCRIPTION` | Shown to the agent โ say what the host is for |
|
|
106
|
-
| `SSH_CWD` | Directory new sessions start in |
|
|
107
|
-
| `SSH_PROXY_COMMAND` | Command whose stdin/stdout carry the SSH stream โ OpenSSH's `ProxyCommand`. `%h` / `%p` / `%r` expand to the first hop's host, port and user, `%%` to a literal `%` |
|
|
108
|
-
| `SSH_JUMP_HOST` | Jump host the target is reached through โ OpenSSH's `ProxyJump` / `-J` |
|
|
109
|
-
| `SSH_JUMP_PORT` | Default `22` |
|
|
110
|
-
| `SSH_JUMP_USER` | Defaults to `SSH_USER` |
|
|
111
|
-
| `SSH_JUMP_KEY` | **Path** to the jump host's private key; defaults to `SSH_KEY` (and then shares `SSH_PASSPHRASE`) |
|
|
112
|
-
| `SSH_JUMP_PASSPHRASE` | Passphrase for `SSH_JUMP_KEY` |
|
|
113
|
-
| `SSH_JUMP_PASSWORD` | Password auth on the jump host |
|
|
114
|
-
| `SSH_JUMP_HOST_KEY` | Pin the jump host's key, `SHA256:โฆ` |
|
|
115
|
-
|
|
116
|
-
Authentication is tried in this order: **private key by path** (with `SSH_PASSPHRASE` if encrypted), **password**, then the **SSH agent** when neither is set (`SSH_AUTH_SOCK`, or the OpenSSH named pipe on Windows). A key path that doesn't exist is reported at startup, not at first connection.
|
|
117
|
-
|
|
118
|
-
### Through a jump host or a proxy command
|
|
119
|
-
|
|
120
|
-
`~/.ssh/config` is **not** read โ the server never shells out to `ssh` โ so a `ProxyJump` or `ProxyCommand` that works on the command line has to be stated in the entry. Both OpenSSH shapes are available, and they compose:
|
|
121
|
-
|
|
122
|
-
```json
|
|
123
|
-
"ssh-dev": {
|
|
124
|
-
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
125
|
-
"env": {
|
|
126
|
-
"SSH_HOST": "10.0.16.12", "SSH_USER": "ubuntu", "SSH_KEY": "~/.ssh/id_ed25519",
|
|
127
|
-
"SSH_JUMP_HOST": "dev-ak-ssh.example.com",
|
|
128
|
-
"SSH_PROXY_COMMAND": "cloudflared access ssh --hostname %h"
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
- **`SSH_JUMP_HOST`** connects to the jump host first and opens a forwarded TCP channel to `SSH_HOST:SSH_PORT`, which the target handshake then runs over โ exactly what `ssh -J` does. The jump host authenticates in the same order from `SSH_JUMP_*` โ user and key fall back to the target's, the password does not โ and its host key can be pinned with `SSH_JUMP_HOST_KEY`. Any `SSH_JUMP_*` set without `SSH_JUMP_HOST` fails startup rather than quietly connecting straight to the target.
|
|
134
|
-
- **`SSH_PROXY_COMMAND`** starts the command through the shell and talks SSH over its stdin/stdout, as `ProxyCommand` does โ `cloudflared access ssh --hostname %h`, `ssh -W %h:%p bastion`, `nc %h %p`. It carries the **first hop**: the jump host when one is set, otherwise the target. The entry above therefore reads "cloudflared to the jump host, then forward to 10.0.16.12".
|
|
135
|
-
- Either alone works too: a jump host reachable directly needs only `SSH_JUMP_HOST`; a host behind a Cloudflare Access tunnel needs only `SSH_PROXY_COMMAND`.
|
|
136
|
-
|
|
137
|
-
The proxy process lives exactly as long as the connection it carries and is killed with it (the whole process tree on Windows). Its stderr goes to the log at `warn`, which is where a `cloudflared` login prompt or a refused tunnel shows up. Log in once with `cloudflared access login <url>` before wiring the entry up: the server cannot open a browser for you, and `SSH_CONNECT_TIMEOUT_MS` expires first.
|
|
138
|
-
|
|
139
|
-
`--check` prints the route and both host key fingerprints, so the jump host's key can be pinned the same way:
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
Checking 10.0.16.12 โ ubuntu@10.0.16.12:22
|
|
143
|
-
via proxy command: cloudflared access ssh --hostname %h
|
|
144
|
-
via jump host: ubuntu@dev-ak-ssh.example.com:22
|
|
145
|
-
|
|
146
|
-
OK ubuntu@10.0.16.12:22 (key /home/me/.ssh/id_ed25519, 1840ms)
|
|
147
|
-
host key SHA256:9x2Kโฆ (not pinned โ set SSH_HOST_KEY to it to detect a changed key)
|
|
148
|
-
jump host key SHA256:Qm7dโฆ (not pinned โ set SSH_JUMP_HOST_KEY to it to detect a changed key)
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### Policy
|
|
152
|
-
|
|
153
|
-
| `SSH_*` variable | Default | Effect |
|
|
154
|
-
|---|---|---|
|
|
155
|
-
| `SSH_READONLY` | `false` | Rejects write commands, output redirection, package installs, uploads and remote writes |
|
|
156
|
-
| `SSH_ALLOW_SUDO` | `true` | When false, rejects `sudo` / `su` / `doas` / `pkexec` / `runuser` / `chroot` |
|
|
157
|
-
| `SSH_ALLOW_COMMANDS` | *(none)* | When set, only these binaries may run โ the strictest lever available (`pwd` is added automatically so sessions can open) |
|
|
158
|
-
| `SSH_DENY_PATTERNS` | *(none)* | Extra regex sources, compiled at startup and tested per command segment |
|
|
159
|
-
| `SSH_ALLOWED_PATHS` | *(none)* | When set, the file tools accept only absolute paths inside these prefixes |
|
|
160
|
-
| `SSH_EXEC_TIMEOUT_MS` | `60000` | Per-command wall clock; the channel is closed when it elapses |
|
|
161
|
-
| `SSH_CONNECT_TIMEOUT_MS` | `20000` | TCP + handshake limit |
|
|
162
|
-
| `SSH_MAX_OUTPUT` | `100000` | stdout and stderr are each truncated past this |
|
|
163
|
-
| `SSH_MAX_READ_BYTES` | `200000` | `ssh_read_file` ceiling |
|
|
164
|
-
|
|
165
|
-
**Defaults are permissive on purpose.** An unconfigured host allows ordinary administration โ installs, service restarts, sudo, interpreters, deployments โ because a guard that blocks real work gets switched off wholesale instead of tuned. Restriction is opt-in.
|
|
166
|
-
|
|
167
|
-
Lists are comma-separated and booleans also accept `1`/`0`, `yes`/`no`, `on`/`off`. A malformed value fails startup rather than being ignored โ `SSH_READONLY=ture` is an error, not "not read-only" โ and `SSH_DENY_PATTERNS` is compiled at startup, so a typo'd regex can't be silently absent at the moment it matters. A missing `SSH_HOST` is not fatal: the server starts with nothing registered and the tools say so.
|
|
168
|
-
|
|
169
|
-
### Several hosts
|
|
170
|
-
|
|
171
|
-
Add a server entry per host. Each one carries its own connection and its own policy, right where you read it, and the agent picks a host by choosing a tool โ there is no host argument to get wrong:
|
|
172
|
-
|
|
173
|
-
```json
|
|
174
|
-
{
|
|
175
|
-
"mcpServers": {
|
|
176
|
-
"ssh-prod": {
|
|
177
|
-
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
178
|
-
"env": { "SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_READONLY": "true" }
|
|
179
|
-
},
|
|
180
|
-
"ssh-dev": {
|
|
181
|
-
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
182
|
-
"env": { "SSH_HOST": "192.168.0.10", "SSH_USER": "ubuntu" }
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
### Check it before wiring it up
|
|
189
|
-
|
|
190
|
-
```bash
|
|
191
|
-
akms-mcp-ssh --check # or: npx -y @akms/mcp-ssh --check
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Connects to the configured host and reports what happened โ exit code 0 when it is reachable. Otherwise a setup mistake is indistinguishable from a policy rejection when the agent tries something. Warnings still print during the check (a missing key file, the proxy command's stderr); add `SSH_MCP_LOG_LEVEL=debug` to see the handshake.
|
|
195
|
-
|
|
196
|
-
```
|
|
197
|
-
Checking prod-web โ deploy@10.0.0.5:22
|
|
198
|
-
|
|
199
|
-
OK deploy@10.0.0.5:22 (key /home/me/.ssh/id_ed25519, 412ms)
|
|
200
|
-
host key SHA256:9x2Kโฆ (not pinned โ set SSH_HOST_KEY to it to detect a changed key)
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
That fingerprint is the same string `ssh-keyscan -t ed25519 host | ssh-keygen -lf -` gives โ copy it into `SSH_HOST_KEY` to pin it. **An unpinned host accepts whatever key the server presents**: fine on a LAN, worth pinning across the internet, and the thing that tells you a host was rebuilt.
|
|
204
|
-
|
|
205
|
-
## ๐งฐ Tools
|
|
206
|
-
|
|
207
|
-
| Tool | Purpose |
|
|
208
|
-
|---|---|
|
|
209
|
-
| `ssh_list_hosts` | The configured host, its route and its guard policy. No credentials |
|
|
210
|
-
| `ssh_connect` | Open a reusable session, returns a session id |
|
|
211
|
-
| `ssh_disconnect` | Close a session |
|
|
212
|
-
| `ssh_list_sessions` | Open sessions with cwd, command count and idle time |
|
|
213
|
-
| `ssh_exec` | Run a command; returns stdout, stderr, exit code, duration |
|
|
214
|
-
| `ssh_list_dir` | SFTP directory listing (type, mode, size, mtime) |
|
|
215
|
-
| `ssh_read_file` | Read a remote text file, truncated to the byte ceiling |
|
|
216
|
-
| `ssh_write_file` | Write or append UTF-8 text over SFTP |
|
|
217
|
-
| `ssh_upload` | Local โ remote file transfer |
|
|
218
|
-
| `ssh_download` | Remote โ local file transfer |
|
|
219
|
-
|
|
220
|
-
### One-off vs session
|
|
221
|
-
|
|
222
|
-
No tool takes a host argument โ the host is the one in this server's env block, so the agent can't name a machine. The only choice is whether to reuse a connection:
|
|
223
|
-
|
|
224
|
-
- **Omit `session`** โ a fresh connection is opened and closed around the single call. Simple, but pays a handshake each time and keeps no state. Capped at 32 concurrent.
|
|
225
|
-
- **Pass `session`** (an id from `ssh_connect`) โ reuses the connection and **remembers the working directory**, so `cd /opt/app` in one `ssh_exec` still applies to the next. Passing `cwd` alongside `session` moves the session there. Capped at 32, reaped after 15 minutes idle but never mid-operation, so a long build or a large transfer is safe. A dropped connection is reported on the next call rather than silently reconnected into a fresh login shell.
|
|
226
|
-
|
|
227
|
-
A non-zero exit code comes back as ordinary output with stdout and stderr for the agent to read; only a genuine failure (connection refused, guard rejection) is flagged as a tool error.
|
|
228
|
-
|
|
229
|
-
### Commands run without a terminal
|
|
230
|
-
|
|
231
|
-
Remote stdin is closed immediately, so anything that waits for input fails fast instead of hanging until the timeout:
|
|
232
|
-
|
|
233
|
-
- `sudo` on a host without `NOPASSWD` fails with `sudo: a password is required` in milliseconds. Either configure `NOPASSWD` for the commands the agent needs, or use `sudo -n` and expect the failure.
|
|
234
|
-
- Confirmation prompts need their non-interactive flag โ `apt-get install -y`, `rm -f`, `ssh-keygen -q -N ''`.
|
|
235
|
-
- Full-screen tools (`top`, `vi`, `htop`) have no TTY. Use `top -bn1`, `ps aux`, and `ssh_write_file` instead of an editor.
|
|
236
|
-
|
|
237
|
-
A long job outliving its timeout keeps running remotely; start it with `nohup โฆ &` or `tmux new -d` and poll the log.
|
|
238
|
-
|
|
239
|
-
## ๐ก๏ธ Guard policy
|
|
240
|
-
|
|
241
|
-
**The threat model is a mistaken agent, not an adversary.** An agent that means well can still type `rm -rf /` with a variable that expanded to nothing, or reboot the host it is currently working through. Those are what the guards stop. One that is *trying* to get around a text-based screen will succeed โ a shell can express the same operation in unlimited ways, and chasing that is how a guard ends up blocking `awk` and getting turned off. So the layers are deliberately few:
|
|
242
|
-
|
|
243
|
-
| Layer | Scope | Examples |
|
|
244
|
-
|---|---|---|
|
|
245
|
-
| **Catastrophe** | Always, not configurable | `rm -rf /` (and `/etc`, `/home`, `~`, `//*`, `/tmp/x /`), `rm /etc/passwd`, `rm /boot/vmlinuz*`, `mkfs`, `dd of=/dev/sda`, `shutdown`, `reboot`, `chmod -R 777 /`, `find / -delete`, `crontab -r`, `iptables -F`, `--no-preserve-root`, fork bombs |
|
|
246
|
-
| `SSH_READONLY` | Opt-in | `rm`/`mv`/`cp`/`mkdir`/`tee`, package installs, editors, `ssh`/`scp`/`wget`, `sed -i`, `sort -o`, `find -delete`, `git push`, `systemctl restart`, `docker run`, `kubectl apply`, `tar -x`, `curl -o`, `> file` redirection |
|
|
247
|
-
| `SSH_ALLOW_SUDO=false` | Opt-in | any segment invoking `sudo`, `su`, `doas`, `pkexec`, `runuser`, `chroot` |
|
|
248
|
-
| `SSH_ALLOW_COMMANDS` / `SSH_DENY_PATTERNS` | Opt-in | whatever you list |
|
|
249
|
-
| Path guards | `SSH_ALLOWED_PATHS` (remote), built-in credential rules (local) | paths outside the allow-list; local credential and config files |
|
|
250
|
-
| `cwd` | Always | a working directory containing shell metacharacters |
|
|
251
|
-
|
|
252
|
-
Everything not in that table runs: installing packages, restarting services, editing configs, `curl โฆ | sh`, deleting an application's own files, `sudo`. `SSH_READONLY` deliberately still allows interpreters (`python3 -c`, `node -e`), `awk`, database clients, `git log`, `docker logs` โ a read-only host exists to be **inspected**, and log analysis needs those. A shell payload is screened either way, so `bash -c "rm x"` is caught by the `rm` rule.
|
|
253
|
-
|
|
254
|
-
Rules are enforced inside `SshConnection.exec` and the SFTP operations โ the layer every command and path must pass through โ so a new tool cannot forget to call the guard. A rejection returns the rule that fired, the offending segment, and the note that nothing was sent. The screening walkthrough is in [README_DEV.md](README_DEV.md#how-a-command-is-screened).
|
|
255
|
-
|
|
256
|
-
`SSH_ALLOWED_PATHS` binds the **file tools only**: `ssh_exec` can still read anything the remote account can, and `ssh_list_hosts` says so. Per-call `timeoutMs` and `maxBytes` are **clamps, not defaults** โ they may lower the configured ceiling, never raise it.
|
|
257
|
-
|
|
258
|
-
### Local path protection
|
|
259
|
-
|
|
260
|
-
The file tools run with this server's own privileges on your machine, so `localPath` is guarded in both directions โ but narrowly, covering **credentials** (`~/.ssh`, `authorized_keys`, private keys, `.pem`/`.key`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker`, `.npmrc`, `.netrc`) and **files that define the guards** (`.claude*` / `.mcp.json` / `claude_desktop_config.json` โ where this server's `SSH_*` variables live โ plus autostart locations). `.env` uploads, `.bashrc` backups and cron fetches are normal deployment work and are allowed. Symlinks are resolved before matching, so a link into `~/.ssh` does not slip past.
|
|
261
|
-
|
|
262
|
-
## ๐ Logging
|
|
263
|
-
|
|
264
|
-
Everything goes to **stderr** (stdout is the MCP protocol channel). `SSH_MCP_LOG_LEVEL` picks the level โ `silent` / `debug` / `info` / `warn` / `error`, default `info`. Guard rejections are logged at `warn` with the full command, and that log is the audit trail for what the agent tried; full command text is otherwise only logged at `debug`, while `password`, `passphrase`, `privateKey` and `token` meta keys are always masked.
|
|
265
|
-
|
|
266
|
-
## โ ๏ธ Security notes
|
|
267
|
-
|
|
268
|
-
**The real boundary is the remote account.** The guards stop mistakes; containment has to come from the host โ give each server entry a dedicated unprivileged user rather than `root`, restrict its `sudoers` entries to the exact commands it needs, pair `SSH_READONLY=true` with filesystem permissions that agree with it, reach for `SSH_ALLOW_COMMANDS` on the strictest hosts, and back up anything you would miss.
|
|
269
|
-
|
|
270
|
-
- The MCP client entry names the host and may hold `SSH_PASSWORD` or `SSH_PASSPHRASE` (and their `SSH_JUMP_*` counterparts). Prefer a key file so only its *path* is stored, keep the entry out of version control, and `chmod 600` it โ the shipped `.gitignore` already excludes `*.pem`, `id_rsa*` and `id_ed25519*`.
|
|
271
|
-
- **Anything the agent can reach, a prompt injection can reach.** Output from a remote host is untrusted input; a file the agent reads can contain instructions. With permissive defaults that matters more, not less โ keep a production entry `SSH_READONLY=true` and let write access be a deliberate exception.
|
|
272
|
-
- The catastrophe rules are the *only* ones that hold regardless of configuration, and even they read the command as text. Treat them as a seatbelt, not a cage.
|
|
273
|
-
- Point this at hosts you are authorized to administer. This is an administration tool, not a scanner.
|
|
274
|
-
- Local paths are resolved against the machine running the server, so on Windows a POSIX-looking `/tmp/x` becomes `<current drive>\tmp\x`. Every transfer reply prints the resolved path โ check it.
|
|
275
|
-
|
|
276
|
-
## ๐งโ๐ป Development
|
|
277
|
-
|
|
278
|
-
Building on this server, embedding it as a library, or changing the guards โ see [README_DEV.md](README_DEV.md).
|
|
279
|
-
|
|
280
|
-
## ๐ License
|
|
281
|
-
|
|
282
|
-
MIT โ see [LICENSE.md](LICENSE.md).
|
|
1
|
+
# @akms/mcp-ssh
|
|
2
|
+
|
|
3
|
+
> ๐ Internal use only โ built for our organization's services.
|
|
4
|
+
|
|
5
|
+
MCP server that lets an AI agent run shell commands and transfer files on a remote host over SSH.
|
|
6
|
+
|
|
7
|
+
The host is **pre-registered** in the server's own environment; the agent never receives a hostname, key or password as an argument, and no tool takes one. Every command and path is screened against the guard policy before anything reaches the network.
|
|
8
|
+
|
|
9
|
+
## ๐ฆ Installation
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18 or later.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @akms/mcp-ssh # installs the `akms-mcp-ssh` command
|
|
15
|
+
npx -y @akms/mcp-ssh --help # or run it straight from the registry
|
|
16
|
+
akms-mcp-ssh --version # show the installed version
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## โ๏ธ MCP client setup
|
|
20
|
+
|
|
21
|
+
**Let the CLI write it.** `config` opens a small manager for the hosts registered in a client's config. It asks plain questions โ host, user, key or password, jump host, read-only โ tests the connection, offers to pin the host key, and writes the entry. No `SSH_*` name has to be remembered.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y @akms/mcp-ssh config # opens on the first client found on this machine
|
|
25
|
+
npx -y @akms/mcp-ssh config --target codex # claude-code | codex | claude-desktop | cursor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Starting in **0.1.5**, `config` is the only configuration command. Update scripts and shortcuts to use `akms-mcp-ssh config`; `--target` works as before. The former `setup` command exits with code 1 and a reminder to use `config`.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Claude Code (user scope) /home/me/.claude.json
|
|
32
|
+
|
|
33
|
+
? 1 SSH host โ pick it to manage it
|
|
34
|
+
โฏ ssh-prod deploy@10.0.0.5:22 key ยท readonly, pinned
|
|
35
|
+
โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
36
|
+
Add a host
|
|
37
|
+
Switch client also found: Codex CLI
|
|
38
|
+
Quit
|
|
39
|
+
โโ move ยท enter select ยท esc back
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The hosts are the menu: pick one to edit, check, print or remove it. Adding or editing a host opens a settings form โ every setting listed with its value, picked and changed in any order, with **Test the connection** and **Save** below. Choose a private key from the keys found in `~/.ssh`, and a route: directly, through a jump host, through a proxy command, or through both.
|
|
43
|
+
|
|
44
|
+
On an interactive terminal:
|
|
45
|
+
|
|
46
|
+
| Keys | Action |
|
|
47
|
+
|---|---|
|
|
48
|
+
| โ / โ, Enter | Move through a menu and select a setting or action |
|
|
49
|
+
| Esc | Go back one level; inside a field or choice, return to the form with that setting unchanged |
|
|
50
|
+
| โ / โ, Home / End | Move the cursor within text being entered |
|
|
51
|
+
| Backspace / Delete, Ctrl+U | Delete characters or clear the current input line |
|
|
52
|
+
| Enter with no input | Keep the displayed value; enter `-` to clear an optional value |
|
|
53
|
+
| Ctrl+C / Ctrl+D | Exit configuration |
|
|
54
|
+
|
|
55
|
+
Esc also undoes a choice when pressed in its follow-up question: choosing a jump route and then pressing Esc at the jump-host prompt restores the previous route. Esc at the **Write it?** preview reopens the form with the entered values. Leaving a changed form with Cancel or Esc asks whether to discard changes.
|
|
56
|
+
|
|
57
|
+
Without an interactive terminal (a pipe, or Git Bash's mintty without winpty), the same menus use numbered choices; enter a number, key or label followed by Enter. After saving, restart the MCP client or reconnect the server from its MCP menu to load the updated entry.
|
|
58
|
+
|
|
59
|
+
| Client | Config file | How it is written |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Claude Code | `~/.claude.json` (user scope) | through `claude mcp add-json โฆ -s user`, never by editing the file |
|
|
62
|
+
| Codex | `~/.codex/config.toml` | only the `[mcp_servers.<name>]` tables are rewritten; keys you added to them stay |
|
|
63
|
+
| Claude Desktop | `claude_desktop_config.json` | `mcpServers.<name>` is set, every other key kept |
|
|
64
|
+
| Cursor | `~/.cursor/mcp.json` | same |
|
|
65
|
+
|
|
66
|
+
Only entries that launch this package are listed or touched. An edit keeps every `SSH_*` variable the wizard does not ask about (timeouts, deny patterns, `SSH_CWD`, โฆ). **Print** shows an entry as JSON, as a Codex TOML table and as a `claude mcp add-json` line, for when pasting is easier. On Windows the entry launches through `cmd /c npx`, the form Claude Code expects for npx servers there.
|
|
67
|
+
|
|
68
|
+
**Or by hand โ one server entry per host** โ plain `SSH_*` variables, no config file, no JSON inside JSON. Claude Code reads `.mcp.json` in the project or `~/.claude.json` globally; Claude Desktop takes the same block inside `claude_desktop_config.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"ssh-prod": {
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["-y", "@akms/mcp-ssh"],
|
|
76
|
+
"env": {
|
|
77
|
+
"SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_KEY": "~/.ssh/id_ed25519",
|
|
78
|
+
"SSH_READONLY": "true", "SSH_ALLOWED_PATHS": "/var/log,/opt/app"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`SSH_HOST` + `SSH_USER` is the whole minimum, and the **private key stays a file on disk** โ only its path goes in the config. Because each server fronts one host, the agent never names it: `ssh_exec({ command: "df -h" })` is a complete call. The server name (`ssh-prod`) is what the agent sees in its tool list, so name it after the host.
|
|
86
|
+
|
|
87
|
+
The server speaks MCP over **stdio**: stdout carries the JSON-RPC stream and all logging goes to stderr. Running it by hand in a terminal only prints the startup line and waits. When the client goes away โ stdin ends, or the process is simply gone โ the server closes its sessions and exits within a few seconds on its own; a stderr nobody reads is never a reason to stay. Its log says `mcp server ready on stdio` with its `pid`, for when one has to be found among a client's processes.
|
|
88
|
+
|
|
89
|
+
## ๐๏ธ Host configuration
|
|
90
|
+
|
|
91
|
+
Every setting is an environment variable in this server's own entry. There is no config file and no JSON โ which is also why "what is this server allowed to do" is answerable by reading the entry above, with no second place to check.
|
|
92
|
+
|
|
93
|
+
### Connection
|
|
94
|
+
|
|
95
|
+
| `SSH_*` variable | Description |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `SSH_HOST` โ
| Hostname or IP |
|
|
98
|
+
| `SSH_USER` โ
| Login user |
|
|
99
|
+
| `SSH_PORT` | Default `22` |
|
|
100
|
+
| `SSH_KEY` | **Path** to a private key file, `~` expanded |
|
|
101
|
+
| `SSH_PASSPHRASE` | Key passphrase |
|
|
102
|
+
| `SSH_PASSWORD` | Password auth, when no key is used |
|
|
103
|
+
| `SSH_HOST_KEY` | Pin the server's host key, `SHA256:โฆ`; a mismatch aborts the connection |
|
|
104
|
+
| `SSH_NAME` | Alias shown to the agent; defaults to the hostname |
|
|
105
|
+
| `SSH_DESCRIPTION` | Shown to the agent โ say what the host is for |
|
|
106
|
+
| `SSH_CWD` | Directory new sessions start in |
|
|
107
|
+
| `SSH_PROXY_COMMAND` | Command whose stdin/stdout carry the SSH stream โ OpenSSH's `ProxyCommand`. `%h` / `%p` / `%r` expand to the first hop's host, port and user, `%%` to a literal `%` |
|
|
108
|
+
| `SSH_JUMP_HOST` | Jump host the target is reached through โ OpenSSH's `ProxyJump` / `-J` |
|
|
109
|
+
| `SSH_JUMP_PORT` | Default `22` |
|
|
110
|
+
| `SSH_JUMP_USER` | Defaults to `SSH_USER` |
|
|
111
|
+
| `SSH_JUMP_KEY` | **Path** to the jump host's private key; defaults to `SSH_KEY` (and then shares `SSH_PASSPHRASE`) |
|
|
112
|
+
| `SSH_JUMP_PASSPHRASE` | Passphrase for `SSH_JUMP_KEY` |
|
|
113
|
+
| `SSH_JUMP_PASSWORD` | Password auth on the jump host |
|
|
114
|
+
| `SSH_JUMP_HOST_KEY` | Pin the jump host's key, `SHA256:โฆ` |
|
|
115
|
+
|
|
116
|
+
Authentication is tried in this order: **private key by path** (with `SSH_PASSPHRASE` if encrypted), **password**, then the **SSH agent** when neither is set (`SSH_AUTH_SOCK`, or the OpenSSH named pipe on Windows). A key path that doesn't exist is reported at startup, not at first connection.
|
|
117
|
+
|
|
118
|
+
### Through a jump host or a proxy command
|
|
119
|
+
|
|
120
|
+
`~/.ssh/config` is **not** read โ the server never shells out to `ssh` โ so a `ProxyJump` or `ProxyCommand` that works on the command line has to be stated in the entry. Both OpenSSH shapes are available, and they compose:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
"ssh-dev": {
|
|
124
|
+
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
125
|
+
"env": {
|
|
126
|
+
"SSH_HOST": "10.0.16.12", "SSH_USER": "ubuntu", "SSH_KEY": "~/.ssh/id_ed25519",
|
|
127
|
+
"SSH_JUMP_HOST": "dev-ak-ssh.example.com",
|
|
128
|
+
"SSH_PROXY_COMMAND": "cloudflared access ssh --hostname %h"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **`SSH_JUMP_HOST`** connects to the jump host first and opens a forwarded TCP channel to `SSH_HOST:SSH_PORT`, which the target handshake then runs over โ exactly what `ssh -J` does. The jump host authenticates in the same order from `SSH_JUMP_*` โ user and key fall back to the target's, the password does not โ and its host key can be pinned with `SSH_JUMP_HOST_KEY`. Any `SSH_JUMP_*` set without `SSH_JUMP_HOST` fails startup rather than quietly connecting straight to the target.
|
|
134
|
+
- **`SSH_PROXY_COMMAND`** starts the command through the shell and talks SSH over its stdin/stdout, as `ProxyCommand` does โ `cloudflared access ssh --hostname %h`, `ssh -W %h:%p bastion`, `nc %h %p`. It carries the **first hop**: the jump host when one is set, otherwise the target. The entry above therefore reads "cloudflared to the jump host, then forward to 10.0.16.12".
|
|
135
|
+
- Either alone works too: a jump host reachable directly needs only `SSH_JUMP_HOST`; a host behind a Cloudflare Access tunnel needs only `SSH_PROXY_COMMAND`.
|
|
136
|
+
|
|
137
|
+
The proxy process lives exactly as long as the connection it carries and is killed with it (the whole process tree on Windows). Its stderr goes to the log at `warn`, which is where a `cloudflared` login prompt or a refused tunnel shows up. Log in once with `cloudflared access login <url>` before wiring the entry up: the server cannot open a browser for you, and `SSH_CONNECT_TIMEOUT_MS` expires first.
|
|
138
|
+
|
|
139
|
+
`--check` prints the route and both host key fingerprints, so the jump host's key can be pinned the same way:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
Checking 10.0.16.12 โ ubuntu@10.0.16.12:22
|
|
143
|
+
via proxy command: cloudflared access ssh --hostname %h
|
|
144
|
+
via jump host: ubuntu@dev-ak-ssh.example.com:22
|
|
145
|
+
|
|
146
|
+
OK ubuntu@10.0.16.12:22 (key /home/me/.ssh/id_ed25519, 1840ms)
|
|
147
|
+
host key SHA256:9x2Kโฆ (not pinned โ set SSH_HOST_KEY to it to detect a changed key)
|
|
148
|
+
jump host key SHA256:Qm7dโฆ (not pinned โ set SSH_JUMP_HOST_KEY to it to detect a changed key)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Policy
|
|
152
|
+
|
|
153
|
+
| `SSH_*` variable | Default | Effect |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `SSH_READONLY` | `false` | Rejects write commands, output redirection, package installs, uploads and remote writes |
|
|
156
|
+
| `SSH_ALLOW_SUDO` | `true` | When false, rejects `sudo` / `su` / `doas` / `pkexec` / `runuser` / `chroot` |
|
|
157
|
+
| `SSH_ALLOW_COMMANDS` | *(none)* | When set, only these binaries may run โ the strictest lever available (`pwd` is added automatically so sessions can open) |
|
|
158
|
+
| `SSH_DENY_PATTERNS` | *(none)* | Extra regex sources, compiled at startup and tested per command segment |
|
|
159
|
+
| `SSH_ALLOWED_PATHS` | *(none)* | When set, the file tools accept only absolute paths inside these prefixes |
|
|
160
|
+
| `SSH_EXEC_TIMEOUT_MS` | `60000` | Per-command wall clock; the channel is closed when it elapses |
|
|
161
|
+
| `SSH_CONNECT_TIMEOUT_MS` | `20000` | TCP + handshake limit |
|
|
162
|
+
| `SSH_MAX_OUTPUT` | `100000` | stdout and stderr are each truncated past this |
|
|
163
|
+
| `SSH_MAX_READ_BYTES` | `200000` | `ssh_read_file` ceiling |
|
|
164
|
+
|
|
165
|
+
**Defaults are permissive on purpose.** An unconfigured host allows ordinary administration โ installs, service restarts, sudo, interpreters, deployments โ because a guard that blocks real work gets switched off wholesale instead of tuned. Restriction is opt-in.
|
|
166
|
+
|
|
167
|
+
Lists are comma-separated and booleans also accept `1`/`0`, `yes`/`no`, `on`/`off`. A malformed value fails startup rather than being ignored โ `SSH_READONLY=ture` is an error, not "not read-only" โ and `SSH_DENY_PATTERNS` is compiled at startup, so a typo'd regex can't be silently absent at the moment it matters. A missing `SSH_HOST` is not fatal: the server starts with nothing registered and the tools say so.
|
|
168
|
+
|
|
169
|
+
### Several hosts
|
|
170
|
+
|
|
171
|
+
Add a server entry per host. Each one carries its own connection and its own policy, right where you read it, and the agent picks a host by choosing a tool โ there is no host argument to get wrong:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"ssh-prod": {
|
|
177
|
+
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
178
|
+
"env": { "SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_READONLY": "true" }
|
|
179
|
+
},
|
|
180
|
+
"ssh-dev": {
|
|
181
|
+
"command": "npx", "args": ["-y", "@akms/mcp-ssh"],
|
|
182
|
+
"env": { "SSH_HOST": "192.168.0.10", "SSH_USER": "ubuntu" }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Check it before wiring it up
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
akms-mcp-ssh --check # or: npx -y @akms/mcp-ssh --check
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Connects to the configured host and reports what happened โ exit code 0 when it is reachable. Otherwise a setup mistake is indistinguishable from a policy rejection when the agent tries something. Warnings still print during the check (a missing key file, the proxy command's stderr); add `SSH_MCP_LOG_LEVEL=debug` to see the handshake.
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Checking prod-web โ deploy@10.0.0.5:22
|
|
198
|
+
|
|
199
|
+
OK deploy@10.0.0.5:22 (key /home/me/.ssh/id_ed25519, 412ms)
|
|
200
|
+
host key SHA256:9x2Kโฆ (not pinned โ set SSH_HOST_KEY to it to detect a changed key)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
That fingerprint is the same string `ssh-keyscan -t ed25519 host | ssh-keygen -lf -` gives โ copy it into `SSH_HOST_KEY` to pin it. **An unpinned host accepts whatever key the server presents**: fine on a LAN, worth pinning across the internet, and the thing that tells you a host was rebuilt.
|
|
204
|
+
|
|
205
|
+
## ๐งฐ Tools
|
|
206
|
+
|
|
207
|
+
| Tool | Purpose |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `ssh_list_hosts` | The configured host, its route and its guard policy. No credentials |
|
|
210
|
+
| `ssh_connect` | Open a reusable session, returns a session id |
|
|
211
|
+
| `ssh_disconnect` | Close a session |
|
|
212
|
+
| `ssh_list_sessions` | Open sessions with cwd, command count and idle time |
|
|
213
|
+
| `ssh_exec` | Run a command; returns stdout, stderr, exit code, duration |
|
|
214
|
+
| `ssh_list_dir` | SFTP directory listing (type, mode, size, mtime) |
|
|
215
|
+
| `ssh_read_file` | Read a remote text file, truncated to the byte ceiling |
|
|
216
|
+
| `ssh_write_file` | Write or append UTF-8 text over SFTP |
|
|
217
|
+
| `ssh_upload` | Local โ remote file transfer |
|
|
218
|
+
| `ssh_download` | Remote โ local file transfer |
|
|
219
|
+
|
|
220
|
+
### One-off vs session
|
|
221
|
+
|
|
222
|
+
No tool takes a host argument โ the host is the one in this server's env block, so the agent can't name a machine. The only choice is whether to reuse a connection:
|
|
223
|
+
|
|
224
|
+
- **Omit `session`** โ a fresh connection is opened and closed around the single call. Simple, but pays a handshake each time and keeps no state. Capped at 32 concurrent.
|
|
225
|
+
- **Pass `session`** (an id from `ssh_connect`) โ reuses the connection and **remembers the working directory**, so `cd /opt/app` in one `ssh_exec` still applies to the next. Passing `cwd` alongside `session` moves the session there. Capped at 32, reaped after 15 minutes idle but never mid-operation, so a long build or a large transfer is safe. A dropped connection is reported on the next call rather than silently reconnected into a fresh login shell.
|
|
226
|
+
|
|
227
|
+
A non-zero exit code comes back as ordinary output with stdout and stderr for the agent to read; only a genuine failure (connection refused, guard rejection) is flagged as a tool error.
|
|
228
|
+
|
|
229
|
+
### Commands run without a terminal
|
|
230
|
+
|
|
231
|
+
Remote stdin is closed immediately, so anything that waits for input fails fast instead of hanging until the timeout:
|
|
232
|
+
|
|
233
|
+
- `sudo` on a host without `NOPASSWD` fails with `sudo: a password is required` in milliseconds. Either configure `NOPASSWD` for the commands the agent needs, or use `sudo -n` and expect the failure.
|
|
234
|
+
- Confirmation prompts need their non-interactive flag โ `apt-get install -y`, `rm -f`, `ssh-keygen -q -N ''`.
|
|
235
|
+
- Full-screen tools (`top`, `vi`, `htop`) have no TTY. Use `top -bn1`, `ps aux`, and `ssh_write_file` instead of an editor.
|
|
236
|
+
|
|
237
|
+
A long job outliving its timeout keeps running remotely; start it with `nohup โฆ &` or `tmux new -d` and poll the log.
|
|
238
|
+
|
|
239
|
+
## ๐ก๏ธ Guard policy
|
|
240
|
+
|
|
241
|
+
**The threat model is a mistaken agent, not an adversary.** An agent that means well can still type `rm -rf /` with a variable that expanded to nothing, or reboot the host it is currently working through. Those are what the guards stop. One that is *trying* to get around a text-based screen will succeed โ a shell can express the same operation in unlimited ways, and chasing that is how a guard ends up blocking `awk` and getting turned off. So the layers are deliberately few:
|
|
242
|
+
|
|
243
|
+
| Layer | Scope | Examples |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| **Catastrophe** | Always, not configurable | `rm -rf /` (and `/etc`, `/home`, `~`, `//*`, `/tmp/x /`), `rm /etc/passwd`, `rm /boot/vmlinuz*`, `mkfs`, `dd of=/dev/sda`, `shutdown`, `reboot`, `chmod -R 777 /`, `find / -delete`, `crontab -r`, `iptables -F`, `--no-preserve-root`, fork bombs |
|
|
246
|
+
| `SSH_READONLY` | Opt-in | `rm`/`mv`/`cp`/`mkdir`/`tee`, package installs, editors, `ssh`/`scp`/`wget`, `sed -i`, `sort -o`, `find -delete`, `git push`, `systemctl restart`, `docker run`, `kubectl apply`, `tar -x`, `curl -o`, `> file` redirection |
|
|
247
|
+
| `SSH_ALLOW_SUDO=false` | Opt-in | any segment invoking `sudo`, `su`, `doas`, `pkexec`, `runuser`, `chroot` |
|
|
248
|
+
| `SSH_ALLOW_COMMANDS` / `SSH_DENY_PATTERNS` | Opt-in | whatever you list |
|
|
249
|
+
| Path guards | `SSH_ALLOWED_PATHS` (remote), built-in credential rules (local) | paths outside the allow-list; local credential and config files |
|
|
250
|
+
| `cwd` | Always | a working directory containing shell metacharacters |
|
|
251
|
+
|
|
252
|
+
Everything not in that table runs: installing packages, restarting services, editing configs, `curl โฆ | sh`, deleting an application's own files, `sudo`. `SSH_READONLY` deliberately still allows interpreters (`python3 -c`, `node -e`), `awk`, database clients, `git log`, `docker logs` โ a read-only host exists to be **inspected**, and log analysis needs those. A shell payload is screened either way, so `bash -c "rm x"` is caught by the `rm` rule.
|
|
253
|
+
|
|
254
|
+
Rules are enforced inside `SshConnection.exec` and the SFTP operations โ the layer every command and path must pass through โ so a new tool cannot forget to call the guard. A rejection returns the rule that fired, the offending segment, and the note that nothing was sent. The screening walkthrough is in [README_DEV.md](README_DEV.md#how-a-command-is-screened).
|
|
255
|
+
|
|
256
|
+
`SSH_ALLOWED_PATHS` binds the **file tools only**: `ssh_exec` can still read anything the remote account can, and `ssh_list_hosts` says so. Per-call `timeoutMs` and `maxBytes` are **clamps, not defaults** โ they may lower the configured ceiling, never raise it.
|
|
257
|
+
|
|
258
|
+
### Local path protection
|
|
259
|
+
|
|
260
|
+
The file tools run with this server's own privileges on your machine, so `localPath` is guarded in both directions โ but narrowly, covering **credentials** (`~/.ssh`, `authorized_keys`, private keys, `.pem`/`.key`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker`, `.npmrc`, `.netrc`) and **files that define the guards** (`.claude*` / `.mcp.json` / `claude_desktop_config.json` โ where this server's `SSH_*` variables live โ plus autostart locations). `.env` uploads, `.bashrc` backups and cron fetches are normal deployment work and are allowed. Symlinks are resolved before matching, so a link into `~/.ssh` does not slip past.
|
|
261
|
+
|
|
262
|
+
## ๐ Logging
|
|
263
|
+
|
|
264
|
+
Everything goes to **stderr** (stdout is the MCP protocol channel). `SSH_MCP_LOG_LEVEL` picks the level โ `silent` / `debug` / `info` / `warn` / `error`, default `info`. Guard rejections are logged at `warn` with the full command, and that log is the audit trail for what the agent tried; full command text is otherwise only logged at `debug`, while `password`, `passphrase`, `privateKey` and `token` meta keys are always masked.
|
|
265
|
+
|
|
266
|
+
## โ ๏ธ Security notes
|
|
267
|
+
|
|
268
|
+
**The real boundary is the remote account.** The guards stop mistakes; containment has to come from the host โ give each server entry a dedicated unprivileged user rather than `root`, restrict its `sudoers` entries to the exact commands it needs, pair `SSH_READONLY=true` with filesystem permissions that agree with it, reach for `SSH_ALLOW_COMMANDS` on the strictest hosts, and back up anything you would miss.
|
|
269
|
+
|
|
270
|
+
- The MCP client entry names the host and may hold `SSH_PASSWORD` or `SSH_PASSPHRASE` (and their `SSH_JUMP_*` counterparts). Prefer a key file so only its *path* is stored, keep the entry out of version control, and `chmod 600` it โ the shipped `.gitignore` already excludes `*.pem`, `id_rsa*` and `id_ed25519*`.
|
|
271
|
+
- **Anything the agent can reach, a prompt injection can reach.** Output from a remote host is untrusted input; a file the agent reads can contain instructions. With permissive defaults that matters more, not less โ keep a production entry `SSH_READONLY=true` and let write access be a deliberate exception.
|
|
272
|
+
- The catastrophe rules are the *only* ones that hold regardless of configuration, and even they read the command as text. Treat them as a seatbelt, not a cage.
|
|
273
|
+
- Point this at hosts you are authorized to administer. This is an administration tool, not a scanner.
|
|
274
|
+
- Local paths are resolved against the machine running the server, so on Windows a POSIX-looking `/tmp/x` becomes `<current drive>\tmp\x`. Every transfer reply prints the resolved path โ check it.
|
|
275
|
+
|
|
276
|
+
## ๐งโ๐ป Development
|
|
277
|
+
|
|
278
|
+
Building on this server, embedding it as a library, or changing the guards โ see [README_DEV.md](README_DEV.md).
|
|
279
|
+
|
|
280
|
+
## ๐ License
|
|
281
|
+
|
|
282
|
+
MIT โ see [LICENSE.md](LICENSE.md).
|