@akms/mcp-ssh 0.0.1 → 0.0.2

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 CHANGED
@@ -1,332 +1,191 @@
1
- # @akms/ssh-mcp
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 remote hosts over SSH.
6
-
7
- Hosts are **pre-registered in a local config file**; the agent picks one by alias and never receives a hostname, key or password as an argument. Every command and path is screened against the profile's guard policy before anything reaches the network.
8
-
9
- ## 📦 Installation
10
-
11
- ```bash
12
- npm install -g @akms/ssh-mcp # installs the `akms-ssh` command
13
- akms-ssh --help
14
-
15
- # or run it straight from the registry, no install
16
- npx -y @akms/ssh-mcp --help
17
- ```
18
-
19
- ## ⚙️ MCP client setup
20
-
21
- **One server entry per host.** Each entry is plain `SSH_*` variables — no config file, no JSON inside JSON.
22
-
23
- Claude Code — `.mcp.json` in the project, or `~/.claude.json` for every project:
24
-
25
- ```json
26
- {
27
- "mcpServers": {
28
- "ssh-dev": {
29
- "command": "npx",
30
- "args": ["-y", "@akms/ssh-mcp"],
31
- "env": {
32
- "SSH_HOST": "192.168.0.10",
33
- "SSH_USER": "ubuntu",
34
- "SSH_KEY": "~/.ssh/id_ed25519"
35
- }
36
- },
37
- "ssh-prod": {
38
- "command": "npx",
39
- "args": ["-y", "@akms/ssh-mcp"],
40
- "env": {
41
- "SSH_HOST": "10.0.0.5",
42
- "SSH_USER": "deploy",
43
- "SSH_KEY": "~/.ssh/id_ed25519",
44
- "SSH_READONLY": "true",
45
- "SSH_ALLOW_SUDO": "false",
46
- "SSH_ALLOWED_PATHS": "/var/log,/opt/app"
47
- }
48
- }
49
- }
50
- }
51
- ```
52
-
53
- `SSH_HOST` + `SSH_USER` is the whole minimum. The **private key stays a file on disk** — only its path goes in the config.
54
-
55
- Because each server fronts one host, the agent never names it: `ssh_exec({ command: "df -h" })` is a complete call. The server name (`ssh-dev`, `ssh-prod`) is what the agent sees in its tool list, so name it after the host.
56
-
57
- Claude Desktop — same block inside `claude_desktop_config.json`.
58
-
59
- 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.
60
-
61
- ## 🗂️ Host configuration
62
-
63
- ### One host per server — `SSH_*` variables
64
-
65
- | Variable | | |
66
- |---|---|---|
67
- | `SSH_HOST` | ✅ | Hostname or IP. Its presence selects single-host mode |
68
- | `SSH_USER` | ✅ | Login user |
69
- | `SSH_PORT` | | Default `22` |
70
- | `SSH_KEY` | | Path to a private key file, `~` expanded |
71
- | `SSH_PASSPHRASE` / `SSH_PASSWORD` | | Key passphrase / password auth |
72
- | `SSH_HOST_KEY` | | Pin the server's host key, `SHA256:…` |
73
- | `SSH_NAME` | | Profile alias; defaults to the hostname |
74
- | `SSH_DESCRIPTION` | | Shown to the agent — say what the host is for |
75
- | `SSH_CWD` | | Directory new sessions start in |
76
- | `SSH_READONLY` | | `true` / `false` (also `1`/`0`, `yes`/`no`, `on`/`off`) |
77
- | `SSH_ALLOW_SUDO` | | Default `true` |
78
- | `SSH_ALLOW_COMMANDS` | | Comma-separated allow-list, e.g. `ls,cat,tail` |
79
- | `SSH_DENY_PATTERNS` | | Comma-separated regex sources |
80
- | `SSH_ALLOWED_PATHS` | | Comma-separated path prefixes for the file tools |
81
- | `SSH_EXEC_TIMEOUT_MS` / `SSH_CONNECT_TIMEOUT_MS` | | Timeouts in ms |
82
- | `SSH_MAX_OUTPUT` / `SSH_MAX_READ_BYTES` | | Output and read ceilings |
83
-
84
- A malformed value fails startup rather than being ignored — `SSH_READONLY=ture` is an error, not "not read-only".
85
-
86
- Regexes containing a comma need the JSON form below.
87
-
88
- ### Several hosts in one server — `SSH_MCP_CONFIG`
89
-
90
- For the case where one entry really should front several hosts. Resolution order overall:
91
-
92
- 1. `--config <path>` CLI flag
93
- 2. `SSH_HOST` — single host from the variables above
94
- 3. `SSH_MCP_CONFIG` — **the JSON itself** if the value starts with `{`, otherwise a path to a JSON file
95
- 4. `~/.akms-ssh.json`
96
- 5. `~/.config/akms-ssh/config.json`
97
-
98
- A JSON object always starts with `{` and a filesystem path never does, so one variable covers both without ambiguity. With several hosts registered, tools take a `target` argument naming one.
99
-
100
- This is the shape, whether inline or in a file:
101
-
102
- ```json
103
- {
104
- "defaults": { "execTimeoutMs": 120000 },
105
- "hosts": {
106
- "dev-box": {
107
- "host": "192.168.0.10",
108
- "username": "ubuntu",
109
- "privateKeyPath": "~/.ssh/id_ed25519"
110
- },
111
- "prod-web": {
112
- "host": "10.0.0.5",
113
- "username": "deploy",
114
- "privateKeyPath": "~/.ssh/id_ed25519",
115
- "passphraseEnv": "PROD_WEB_KEY_PASSPHRASE",
116
- "readonly": true,
117
- "allowSudo": false,
118
- "allowedPaths": ["/var/log", "/opt/app"]
119
- }
120
- }
121
- }
122
- ```
123
-
124
- A missing config is not fatal — the server starts with zero profiles and the tools say so. A config that **exists but is malformed** aborts startup, so a typo in a policy field can never silently drop a guard.
125
-
126
- That includes *unknown* keys: `readOnly` (capital O) is rejected rather than ignored. A schema that silently dropped it would leave a host you believed was read-only fully writable.
127
-
128
- **Inline or file?** Inline keeps everything in one place and travels with the MCP config. A file is easier to read and edit, and can be `chmod 600` — the server warns if it is group/world readable.
129
-
130
- ### Check it before wiring it up
131
-
132
- ```bash
133
- akms-ssh --check # or: npx -y @akms/ssh-mcp --check
134
- ```
135
-
136
- Connects to every profile and reports what happened — exit code 0 when all are reachable:
137
-
138
- ```
139
- Checking 3 profile(s) from inline SSH_MCP_CONFIG
140
-
141
- OK dev-box → ubuntu@192.168.0.10:22 (key ~/.ssh/id_ed25519, 412ms)
142
- host key SHA256:9x2K… (not pinned — add it as hostKeyFingerprint to detect a changed key)
143
- FAILED prod-web → deploy@10.0.0.5:22 (key /home/me/.ssh/id_ed25519)
144
- All configured authentication methods failed
145
-
146
- 1 of 2 reachable
147
- ```
148
-
149
- Without this the only way to test a config is to ask the agent to try something, which confuses a setup mistake with a policy rejection. Add `SSH_MCP_LOG_LEVEL=debug` to see the handshake.
150
-
151
- ### Host key pinning
152
-
153
- The fingerprint printed above is the same string `ssh-keyscan -t ed25519 host | ssh-keygen -lf -` gives. Copy it into the profile to pin it:
154
-
155
- ```json
156
- "prod-web": { "host": "10.0.0.5", "username": "deploy", "hostKeyFingerprint": "SHA256:9x2K…" }
157
- ```
158
-
159
- A mismatch then aborts the connection. **Unpinned profiles accept whatever key the server presents** — fine on a LAN, worth pinning across the internet, and the thing that tells you a host was rebuilt.
160
-
161
- ### Connection fields
162
-
163
- | Field | Required | Description |
164
- |---|---|---|
165
- | `host` | ✅ | Hostname or IP |
166
- | `username` | ✅ | Login user |
167
- | `port` | | Default `22` |
168
- | `description` | | Shown in `ssh_list_hosts`; tell the agent what the host is for |
169
- | `defaultCwd` | | Directory new sessions start in |
170
- | `privateKeyPath` | | **Path** to a private key file, `~` expanded — the usual choice |
171
- | `privateKey` | | Key contents, for the rare case where no file exists |
172
- | `passphrase` / `passphraseEnv` | | Key passphrase, inline or from an env var |
173
- | `password` / `passwordEnv` | | Password auth, inline or from an env var |
174
- | `hostKeyFingerprint` | | Pin the server's host key, e.g. `SHA256:9x2K…`; a mismatch aborts the connection |
175
-
176
- Three ways to authenticate, tried in this order:
177
-
178
- 1. **Private key by path** — `"privateKeyPath": "~/.ssh/id_ed25519"`, with `passphraseEnv` if the key is encrypted. The key never enters the config; only its path does. A path that doesn't exist is reported at startup rather than at first connection.
179
- 2. **Password** — `"passwordEnv": "PROD_PASSWORD"` keeps it out of the JSON.
180
- 3. **SSH agent** — used automatically when neither key nor password is set (`SSH_AUTH_SOCK`, or the OpenSSH named pipe on Windows).
181
-
182
- `*Env` variants are preferred: the secret stays in your shell or keychain. When the named variable is empty the inline value is used and a warning is logged.
183
-
184
- ### Policy fields
185
-
186
- Settable in `defaults` (file-wide) and per host. Resolution order: **built-in → `defaults` → host override**.
187
-
188
- | Field | Default | Effect |
189
- |---|---|---|
190
- | `readonly` | `false` | Rejects write commands, output redirection, package installs, uploads and remote writes |
191
- | `allowSudo` | `true` | When false, rejects `sudo` / `su` / `doas` / `pkexec` / `runuser` / `chroot` |
192
- | `allowCommands` | `[]` | When non-empty, only these binaries may run — the strictest lever available (`pwd` is added automatically so sessions can open) |
193
- | `denyPatterns` | `[]` | Extra regex sources, compiled at startup and tested per command segment |
194
- | `allowedPaths` | `[]` | When non-empty, the file tools accept only absolute paths inside these prefixes |
195
- | `execTimeoutMs` | `60000` | Per-command wall clock; the channel is closed when it elapses |
196
- | `connectTimeoutMs` | `20000` | TCP + handshake limit |
197
- | `maxOutputCharacters` | `100000` | stdout and stderr are each truncated past this |
198
- | `maxReadFileBytes` | `200000` | `ssh_read_file` ceiling |
199
-
200
- **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, per host.
201
-
202
- An explicitly empty array (`"allowCommands": []`) on a host *overrides* a restrictive `defaults` block and is warned about at startup, since "nothing extra" and "no restriction" are opposite readings of the same syntax.
203
-
204
- ## 🧰 Tools
205
-
206
- | Tool | Purpose |
207
- |---|---|
208
- | `ssh_list_hosts` | Registered profiles with each one's guard policy. No credentials |
209
- | `ssh_reload_config` | Re-read the config after you edit it, without restarting the client |
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
- ### Naming the host
221
-
222
- With a single configured host — the intended setup — `target` is omitted entirely and every tool uses that host. It becomes required only when a server fronts several, and the error then lists them.
223
-
224
- ### One-off vs session
225
-
226
- Every operation tool takes **either** `target` (a profile alias) **or** `session` (an id from `ssh_connect`) — passing both is an error, since they can name different hosts under different policies:
227
-
228
- - `target` — a fresh connection is opened and closed around the single call. Simple, but pays a handshake each time and keeps no state. Capped at 16 concurrent.
229
- - `session` — 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.
230
-
231
- Sessions are capped at 32 and reaped after 15 minutes idle — but never while an operation is running, so a long build or a large transfer is safe.
232
-
233
- If the connection drops, the session says so on the next call instead of silently reconnecting. A reconnect would hand back a fresh login shell — environment variables, sudo cache and background jobs gone — while the model still believed it held the old one.
234
-
235
- ### Exit codes are not errors
236
-
237
- A non-zero exit code comes back as ordinary output with stdout and stderr — the agent reads it and decides. Only a genuine failure (unknown profile, connection refused, guard rejection) is flagged as a tool error.
238
-
239
- ### Commands run without a terminal
240
-
241
- Remote stdin is closed immediately, so anything that waits for input fails fast instead of hanging until the timeout:
242
-
243
- - `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.
244
- - Confirmation prompts need their non-interactive flag — `apt-get install -y`, `rm -f`, `ssh-keygen -q -N ''`.
245
- - Full-screen tools (`top`, `vi`, `htop`) have no TTY. Use `top -bn1`, `ps aux`, and `ssh_write_file` instead of an editor.
246
-
247
- A long job outliving its timeout keeps running remotely; start it with `nohup … &` or `tmux new -d` and poll the log.
248
-
249
- ## 🛡️ Guard policy
250
-
251
- **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. An agent — or a prompt injection — that is *trying* to get around a text-based screen will succeed, because a shell can express the same operation in unlimited ways. Chasing that is how a guard ends up blocking `awk` and getting turned off.
252
-
253
- So the layers are:
254
-
255
- | Layer | Scope | Examples |
256
- |---|---|---|
257
- | **Catastrophe** | Every profile, 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 |
258
- | `readonly` | Opt-in per profile | `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 |
259
- | `allowSudo: false` | Opt-in per profile | any segment invoking `sudo`, `su`, `doas`, `pkexec`, `runuser`, `chroot` |
260
- | `allowCommands` / `denyPatterns` | Opt-in per profile | whatever you list |
261
- | Path guards | `allowedPaths` (remote), built-in credential rules (local) | paths outside the allow-list; local credential and config files |
262
- | `cwd` | Every profile | a working directory containing shell metacharacters |
263
-
264
- Everything not in that table runs. Installing packages, restarting services, editing configs, `curl … \| sh`, deleting an application's own files, `sudo` — all ordinary work, all allowed by default.
265
-
266
- ### What `readonly` deliberately allows
267
-
268
- Interpreters (`python3 -c`, `node -e`, `perl -ne`), `awk`, database clients, `git log`, `docker logs`, `tar -tzf`. A read-only host exists to be **inspected**, and log analysis needs those. Blocking them made the mode unusable for its own purpose while stopping nobody — a shell payload is still screened, so `bash -c "rm x"` is caught by the `rm` rule.
269
-
270
- ### How a command is screened
271
-
272
- 1. **Canonicalize** — `\`+newline continuations are removed (as POSIX does) and <code>$&#123;IFS&#125;</code> expands to a space. The canonical text is what gets screened **and** what gets sent; validating one string and running another is a bypass by construction.
273
- 2. **Pipeline rules** — fork bombs.
274
- 3. **Nested commands** — `$(…)`, backticks and process substitution `<(…)` are each screened as their own command, recursively.
275
- 4. **Split into segments** on `;`, `&&`, `||`, `|`, `&` and newlines. Quoted text stays intact, so `echo "a; b"` is one segment.
276
- 5. **Per segment**, the binary that actually runs is resolved by stepping past `FOO=bar` assignments, redirection tokens, shell keywords (`if`, `then`, `{`, `(`), wrapper binaries (`sudo`, `env`, `nohup`, `timeout`, `eval`, `busybox`, …), their numeric operands and their flag values — so `timeout 30 sudo rm -rf /` resolves to `rm`, not `30`, and `env -u PATH rm -rf /` resolves to `rm`, not `PATH`. The binary is reduced to its basename, so `/sbin/reboot` matches the same rule as `reboot`.
277
- 6. **Nested payloads** — a `-c "…"` string (including bundled forms like `bash -lc`) or a `--` argv tail is screened as its own command.
278
-
279
- Every rule is 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.
280
-
281
- A rejection returns the rule that fired, the offending segment, and the note that nothing was sent.
282
-
283
- `allowedPaths` binds the **file tools only**. `ssh_exec` can still read anything the remote account can — `ssh_list_hosts` says so on each profile.
284
-
285
- `timeoutMs` and `maxBytes` are **clamps, not defaults**: a per-call value may lower the profile's ceiling, never raise it.
286
-
287
- ### The real boundary is the remote account
288
-
289
- Since the guards are a mistake-stopper, containment has to come from the host:
290
-
291
- - give each profile a **dedicated unprivileged user**, not `root`
292
- - restrict that user's `sudoers` entries to the exact commands it needs
293
- - pair `readonly: true` with filesystem permissions that agree with it
294
- - for the strictest hosts use `allowCommands` — an allow-list beats any deny-list
295
- - snapshot or back up anything you would miss
296
-
297
- ### Local path protection
298
-
299
- The file tools run with this server's own privileges on your machine, so `localPath` is guarded in both directions — but narrowly, covering two things only:
300
-
301
- - **Credentials**: `~/.ssh`, `authorized_keys`, private keys, `.pem`/`.key`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker`, `.npmrc`, `.netrc`
302
- - **Files that define the guards**: this server's config, `.claude*` / `.mcp.json` / `claude_desktop_config.json`, and autostart locations
303
-
304
- `.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.
305
-
306
- ## 📄 Logging
307
-
308
- Everything goes to **stderr** (stdout is the MCP protocol channel).
309
-
310
- | Variable | Description | Default |
311
- |---|---|---|
312
- | `SSH_MCP_CONFIG` | Host profiles as JSON (value starts with `{`), or a path to a JSON file | `~/.akms-ssh.json` |
313
- | `SSH_MCP_LOG_LEVEL` | `silent` / `debug` / `info` / `warn` / `error` | `info` |
314
-
315
- Guard rejections are logged at `warn` with the full command — that log is the audit trail for what the agent tried. Full command text is otherwise only logged at `debug`; `password`, `passphrase`, `privateKey` and `token` meta keys are always masked.
316
-
317
- ## ⚠️ Security notes
318
-
319
- - The config names hosts and may hold passwords. Inline in an MCP config means that file now carries them — treat `.mcp.json` accordingly, and prefer `passwordEnv` / `passphraseEnv` so only the *name* of a variable is stored. A config file should be `chmod 600` and never committed; the shipped `.gitignore` already excludes `.akms-ssh.json`, `*.pem`, `id_rsa*` and `id_ed25519*`.
320
- - **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 production profiles `readonly` and let write access be a deliberate exception.
321
- - The catastrophe rules are the *only* ones that hold on every profile, and even they read the command as text. Treat them as a seatbelt, not a cage.
322
- - Point profiles at hosts you are authorized to administer. This is an administration tool, not a scanner.
323
- - 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.
324
- - `timeoutMs` closes the local channel; the remote process may keep running. For long jobs use `nohup`/`tmux` and poll.
325
-
326
- ## 🧑‍💻 Development
327
-
328
- Building on this server, embedding it as a library, or changing the guards — see [README_DEV.md](README_DEV.md).
329
-
330
- ## 📜 License
331
-
332
- 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
+ ```bash
12
+ npm install -g @akms/mcp-ssh # installs the `akms-mcp-ssh` command
13
+ npx -y @akms/mcp-ssh --help # or run it straight from the registry
14
+ ```
15
+
16
+ ## ⚙️ MCP client setup
17
+
18
+ **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`:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "ssh-prod": {
24
+ "command": "npx",
25
+ "args": ["-y", "@akms/mcp-ssh"],
26
+ "env": {
27
+ "SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_KEY": "~/.ssh/id_ed25519",
28
+ "SSH_READONLY": "true", "SSH_ALLOWED_PATHS": "/var/log,/opt/app"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ `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.
36
+
37
+ 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.
38
+
39
+ ## 🗂️ Host configuration
40
+
41
+ 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.
42
+
43
+ ### Connection
44
+
45
+ | `SSH_*` variable | Description |
46
+ |---|---|
47
+ | `SSH_HOST` ✅ | Hostname or IP |
48
+ | `SSH_USER` ✅ | Login user |
49
+ | `SSH_PORT` | Default `22` |
50
+ | `SSH_KEY` | **Path** to a private key file, `~` expanded |
51
+ | `SSH_PASSPHRASE` | Key passphrase |
52
+ | `SSH_PASSWORD` | Password auth, when no key is used |
53
+ | `SSH_HOST_KEY` | Pin the server's host key, `SHA256:…`; a mismatch aborts the connection |
54
+ | `SSH_NAME` | Alias shown to the agent; defaults to the hostname |
55
+ | `SSH_DESCRIPTION` | Shown to the agent — say what the host is for |
56
+ | `SSH_CWD` | Directory new sessions start in |
57
+
58
+ 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.
59
+
60
+ ### Policy
61
+
62
+ | `SSH_*` variable | Default | Effect |
63
+ |---|---|---|
64
+ | `SSH_READONLY` | `false` | Rejects write commands, output redirection, package installs, uploads and remote writes |
65
+ | `SSH_ALLOW_SUDO` | `true` | When false, rejects `sudo` / `su` / `doas` / `pkexec` / `runuser` / `chroot` |
66
+ | `SSH_ALLOW_COMMANDS` | *(none)* | When set, only these binaries may run — the strictest lever available (`pwd` is added automatically so sessions can open) |
67
+ | `SSH_DENY_PATTERNS` | *(none)* | Extra regex sources, compiled at startup and tested per command segment |
68
+ | `SSH_ALLOWED_PATHS` | *(none)* | When set, the file tools accept only absolute paths inside these prefixes |
69
+ | `SSH_EXEC_TIMEOUT_MS` | `60000` | Per-command wall clock; the channel is closed when it elapses |
70
+ | `SSH_CONNECT_TIMEOUT_MS` | `20000` | TCP + handshake limit |
71
+ | `SSH_MAX_OUTPUT` | `100000` | stdout and stderr are each truncated past this |
72
+ | `SSH_MAX_READ_BYTES` | `200000` | `ssh_read_file` ceiling |
73
+
74
+ **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.
75
+
76
+ 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.
77
+
78
+ ### Several hosts
79
+
80
+ 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:
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "ssh-prod": {
86
+ "command": "npx", "args": ["-y", "@akms/mcp-ssh"],
87
+ "env": { "SSH_HOST": "10.0.0.5", "SSH_USER": "deploy", "SSH_READONLY": "true" }
88
+ },
89
+ "ssh-dev": {
90
+ "command": "npx", "args": ["-y", "@akms/mcp-ssh"],
91
+ "env": { "SSH_HOST": "192.168.0.10", "SSH_USER": "ubuntu" }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ### Check it before wiring it up
98
+
99
+ ```bash
100
+ akms-mcp-ssh --check # or: npx -y @akms/mcp-ssh --check
101
+ ```
102
+
103
+ 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. Add `SSH_MCP_LOG_LEVEL=debug` to see the handshake.
104
+
105
+ ```
106
+ Checking prod-web → deploy@10.0.0.5:22
107
+
108
+ OK deploy@10.0.0.5:22 (key /home/me/.ssh/id_ed25519, 412ms)
109
+ host key SHA256:9x2K… (not pinned — set SSH_HOST_KEY to it to detect a changed key)
110
+ ```
111
+
112
+ 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.
113
+
114
+ ## 🧰 Tools
115
+
116
+ | Tool | Purpose |
117
+ |---|---|
118
+ | `ssh_list_hosts` | The configured host and its guard policy. No credentials |
119
+ | `ssh_connect` | Open a reusable session, returns a session id |
120
+ | `ssh_disconnect` | Close a session |
121
+ | `ssh_list_sessions` | Open sessions with cwd, command count and idle time |
122
+ | `ssh_exec` | Run a command; returns stdout, stderr, exit code, duration |
123
+ | `ssh_list_dir` | SFTP directory listing (type, mode, size, mtime) |
124
+ | `ssh_read_file` | Read a remote text file, truncated to the byte ceiling |
125
+ | `ssh_write_file` | Write or append UTF-8 text over SFTP |
126
+ | `ssh_upload` | Local → remote file transfer |
127
+ | `ssh_download` | Remote → local file transfer |
128
+
129
+ ### One-off vs session
130
+
131
+ 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:
132
+
133
+ - **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.
134
+ - **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.
135
+
136
+ 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.
137
+
138
+ ### Commands run without a terminal
139
+
140
+ Remote stdin is closed immediately, so anything that waits for input fails fast instead of hanging until the timeout:
141
+
142
+ - `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.
143
+ - Confirmation prompts need their non-interactive flag — `apt-get install -y`, `rm -f`, `ssh-keygen -q -N ''`.
144
+ - Full-screen tools (`top`, `vi`, `htop`) have no TTY. Use `top -bn1`, `ps aux`, and `ssh_write_file` instead of an editor.
145
+
146
+ A long job outliving its timeout keeps running remotely; start it with `nohup … &` or `tmux new -d` and poll the log.
147
+
148
+ ## 🛡️ Guard policy
149
+
150
+ **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:
151
+
152
+ | Layer | Scope | Examples |
153
+ |---|---|---|
154
+ | **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 |
155
+ | `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 |
156
+ | `SSH_ALLOW_SUDO=false` | Opt-in | any segment invoking `sudo`, `su`, `doas`, `pkexec`, `runuser`, `chroot` |
157
+ | `SSH_ALLOW_COMMANDS` / `SSH_DENY_PATTERNS` | Opt-in | whatever you list |
158
+ | Path guards | `SSH_ALLOWED_PATHS` (remote), built-in credential rules (local) | paths outside the allow-list; local credential and config files |
159
+ | `cwd` | Always | a working directory containing shell metacharacters |
160
+
161
+ 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.
162
+
163
+ 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).
164
+
165
+ `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.
166
+
167
+ ### Local path protection
168
+
169
+ 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.
170
+
171
+ ## 📄 Logging
172
+
173
+ 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.
174
+
175
+ ## ⚠️ Security notes
176
+
177
+ **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.
178
+
179
+ - The MCP client entry names the host and may hold `SSH_PASSWORD` or `SSH_PASSPHRASE`. 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*`.
180
+ - **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.
181
+ - 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.
182
+ - Point this at hosts you are authorized to administer. This is an administration tool, not a scanner.
183
+ - 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.
184
+
185
+ ## 🧑‍💻 Development
186
+
187
+ Building on this server, embedding it as a library, or changing the guards — see [README_DEV.md](README_DEV.md).
188
+
189
+ ## 📜 License
190
+
191
+ MIT — see [LICENSE.md](LICENSE.md).