@yawlabs/ssh-mcp 0.17.2 → 0.18.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 +9 -5
- package/bin/ssh-mcp.mjs +81 -11
- package/dist/index.js +185 -54
- package/dist/policy.d.ts +1 -1
- package/dist/server.d.ts +12 -5
- package/dist/server.js +189 -54
- package/dist/ssh.d.ts +38 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -87,8 +87,8 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
|
|
|
87
87
|
|
|
88
88
|
| Tool | Description |
|
|
89
89
|
|------|-------------|
|
|
90
|
-
| `ssh_exec` | Execute a command on a remote host. Returns
|
|
91
|
-
| `ssh_read_file` | Read a file from a remote host via SFTP. |
|
|
90
|
+
| `ssh_exec` | Execute a command on a remote host. Returns up to three content blocks, status first: `[exit code: N]` (plus `[signal: NAME]` with `code: -1` when the channel closed signal-only, and a flag when a stream hit the 10 MB cap), then `[stderr]`, then `[stdout]`. Status leads so a proxy that cuts long results — Yaw MCP keeps about 100 KB by default — never drops the exit code. Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
|
|
91
|
+
| `ssh_read_file` | Read a file from a remote host via SFTP (whole file up to 10 MB). Optional `offset` and `length` (bytes) read one page and return a `[bytes A-B of SIZE; next offset: N]` status block before the text — use them for a large file, or behind a proxy that caps results. |
|
|
92
92
|
| `ssh_write_file` | Write content to a file on a remote host via SFTP. |
|
|
93
93
|
| `ssh_upload` | Upload a local file to a remote host via SFTP. |
|
|
94
94
|
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
@@ -137,11 +137,11 @@ All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
|
137
137
|
|
|
138
138
|
- **Known host, key matches** — accept.
|
|
139
139
|
- **Known host, key changed** — reject (MITM protection). The rejection message distinguishes a genuine key mismatch from "the server offered a key type your `known_hosts` entry doesn't cover", so a missing ed25519 line doesn't read as an attack.
|
|
140
|
-
- **Unknown host** — accept, unless `
|
|
140
|
+
- **Unknown host** — accept, unless strict mode is on (`SSH_MCP_STRICT_HOSTKEYS=1`).
|
|
141
141
|
|
|
142
142
|
**That last branch is trust-always, not TOFU.** Real trust-on-first-use pins the key it saw the first time and rejects a change afterwards. The connection path never writes to `known_hosts` — no tool adds an entry as a side effect of connecting — so connecting pins nothing: *every* connection to a host absent from `known_hosts` is a "first" use and is accepted, including one where an attacker swapped the key since your last call. Only hosts put into `known_hosts` out of band get mismatch protection — by you, by `ssh-keyscan`, or by `ssh_known_hosts_fix`, the one tool here that does write the file. Call it explicitly to add an entry so future changes are caught.
|
|
143
143
|
|
|
144
|
-
For stricter environments, set `
|
|
144
|
+
For stricter environments, set `SSH_MCP_STRICT_HOSTKEYS=1` (or `true`, case-insensitive) to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first. The older name `SSH_MCP_STRICT_HOST_KEY` still works and accepts the same values, but prefer the new one: credential scanners such as Yaw MCP's doctor treat any name with a `_KEY` segment as a secret, and flag the old name as a plaintext credential.
|
|
145
145
|
|
|
146
146
|
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
147
147
|
|
|
@@ -207,6 +207,8 @@ If you don't trust the agent's `env` values at all, the simplest mitigation is t
|
|
|
207
207
|
|
|
208
208
|
On Windows, ssh-mcp uses the OpenSSH Authentication Agent's `\\.\pipe\openssh-ssh-agent` named pipe automatically when `SSH_AUTH_SOCK` is not set. No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
209
209
|
|
|
210
|
+
On the [oam](https://oamjs.org) runtime, agent auth needs oam 0.18.0 or newer: that is the first oam whose `net` can dial a named pipe or a Unix socket, which is how ssh2 reaches the agent (and the launcher never serves on an older one). Agent-backed remote operations have so far been verified end to end on Node only; on oam, the handshake and `ssh_config_lookup` were verified, not an agent-only `ssh_exec`. If agent auth fails on oam, set `SSH_MCP_RUNTIME=node` to rule the runtime out.
|
|
211
|
+
|
|
210
212
|
`ssh_agent_ensure` and `ssh_diagnose` probe that pipe and tell you if the service is down. Remote operations do *not*: they assume the pipe and let the connection fail on its own if the agent isn't there. That is why a stopped agent service shows up as an auth failure rather than an "agent not running" error until you run the diagnostic tools.
|
|
211
213
|
|
|
212
214
|
## Authentication
|
|
@@ -336,7 +338,7 @@ Two environment variables control the choice; set them in your MCP client's `env
|
|
|
336
338
|
- `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
|
|
337
339
|
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.18.0 or newer. If it does not exist, is older, or will not run, the launcher says so on stderr and carries on with discovery.
|
|
338
340
|
|
|
339
|
-
Discovery looks in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.18.0 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
|
|
341
|
+
Discovery looks in `$OAM_INSTALL_DIR` (when set — the oam installer's install target), then `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.18.0 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
|
|
340
342
|
|
|
341
343
|
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.18.0, not runnable, or a shim. A chosen oam that then fails to start is named on stderr too. When an older oam starts the launcher, the handoff to Node is always noted on stderr (see below). With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
|
|
342
344
|
|
|
@@ -346,6 +348,8 @@ When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, wh
|
|
|
346
348
|
- On an older oam it never runs the server there. It hands the server off to the newest usable oam it can find, or to Node on `PATH`, or exits with status 1 when there is neither (`SSH_MCP_RUNTIME=oam` exits with status 1 rather than handing off to Node).
|
|
347
349
|
- With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
|
|
348
350
|
|
|
351
|
+
Whenever an oam host starts a child (the handoffs above), the launcher removes `--permission` and `--allow-*` flags from the child's inherited `NODE_OPTIONS`. oam 0.18.0 adds its own permission flags to every child's `NODE_OPTIONS`, and Node refuses some of them there (`--allow-net is not allowed in NODE_OPTIONS`, exit 9); this server is not sandboxed, so there is nothing to pass on. An oam that was itself started with `--permission` re-adds the flags inside `spawn()`, which the launcher cannot undo, so do not start it that way.
|
|
352
|
+
|
|
349
353
|
For example, to always run on Node:
|
|
350
354
|
|
|
351
355
|
```json
|
package/bin/ssh-mcp.mjs
CHANGED
|
@@ -79,13 +79,29 @@
|
|
|
79
79
|
* MINIMUM OAM VERSION
|
|
80
80
|
* The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
|
|
81
81
|
* Only the current oam is used and verified; an older one is passed over.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
82
|
+
*
|
|
83
|
+
* The floor is not cosmetic. Two things this server does need 0.18.0 itself,
|
|
84
|
+
* so anyone lowering it has to answer for both:
|
|
85
|
+
*
|
|
86
|
+
* 1. An explicit child `env` that DELETES a variable. probeAgent
|
|
87
|
+
* (src/env.ts:85-90) runs `ssh-add -l` with SSH_AUTH_SOCK removed, so
|
|
88
|
+
* Windows ssh-add falls back to the OpenSSH named pipe instead of a stale
|
|
89
|
+
* Unix-style socket. Up to 0.17.x oam laid an explicit `env` OVER its
|
|
90
|
+
* startup environment, so the deleted SSH_AUTH_SOCK came back and ssh-add
|
|
91
|
+
* probed the wrong agent. 0.18.0 makes an explicit `env` replace it, as
|
|
92
|
+
* Node does (oam CHANGELOG 0.18.0, B4).
|
|
93
|
+
* 2. `net.connect` to a Windows named pipe or a Unix socket path.
|
|
94
|
+
* resolveConfig (src/ssh.ts:581-584) hands ssh2 SSH_AUTH_SOCK, or on Windows
|
|
95
|
+
* `\\.\pipe\openssh-ssh-agent`, and ssh2 dials it with
|
|
96
|
+
* `new Socket().connect(path)` for every agent-backed connection. Up to
|
|
97
|
+
* 0.17.x oam could not dial either (oam CHANGELOG 0.18.0, F1).
|
|
98
|
+
*
|
|
99
|
+
* Background, long before the current floor: before 0.9.0
|
|
100
|
+
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
101
|
+
* `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
|
|
102
|
+
* reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
|
|
103
|
+
* `'pipe'`. This server shells out to ssh, ssh-add and ssh-keygen on its main
|
|
104
|
+
* paths, so those were reachable bugs rather than theoretical ones.
|
|
89
105
|
*
|
|
90
106
|
* SELECTION
|
|
91
107
|
* SSH_MCP_RUNTIME=auto newest usable oam, else Node (default)
|
|
@@ -103,7 +119,12 @@ import { constants, homedir } from "node:os";
|
|
|
103
119
|
import { delimiter, join } from "node:path";
|
|
104
120
|
import { fileURLToPath } from "node:url";
|
|
105
121
|
|
|
106
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Oldest oam this server runs on: the one whose explicit child `env` replaces
|
|
124
|
+
* the environment (needed by probeAgent, src/env.ts) and whose `net` dials named
|
|
125
|
+
* pipes and Unix sockets (needed by ssh2's agent client, src/ssh.ts). See MINIMUM
|
|
126
|
+
* OAM VERSION above.
|
|
127
|
+
*/
|
|
107
128
|
const OAM_MIN = [0, 18, 0];
|
|
108
129
|
|
|
109
130
|
/**
|
|
@@ -141,8 +162,12 @@ function pathKey(p) {
|
|
|
141
162
|
* usually has oam/target/release on PATH, and cargo replaces that binary
|
|
142
163
|
* underneath running processes; the installed copy is the release the user
|
|
143
164
|
* actually installed. Both forms are checked on Windows: the installer defaults
|
|
144
|
-
* to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first
|
|
145
|
-
*
|
|
165
|
+
* to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first.
|
|
166
|
+
*
|
|
167
|
+
* OAM_INSTALL_DIR, when set, is searched before both. It is the installer's
|
|
168
|
+
* install target (oam docs/cli-reference.md), so an oam put somewhere custom and
|
|
169
|
+
* left off PATH is still found -- this comment used to say it "can pick either"
|
|
170
|
+
* default while the code never read it.
|
|
146
171
|
*
|
|
147
172
|
* Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
148
173
|
* run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
|
|
@@ -156,6 +181,7 @@ function discoverOamPaths() {
|
|
|
156
181
|
if (isWin) {
|
|
157
182
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
158
183
|
}
|
|
184
|
+
if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
|
|
159
185
|
const onPath = (process.env.PATH ?? "")
|
|
160
186
|
.split(delimiter)
|
|
161
187
|
.filter(Boolean)
|
|
@@ -405,6 +431,48 @@ const fallbackFailed = (e) => {
|
|
|
405
431
|
process.exitCode = 1;
|
|
406
432
|
};
|
|
407
433
|
|
|
434
|
+
/**
|
|
435
|
+
* A permission flag oam may have put in NODE_OPTIONS: `--permission` (and the
|
|
436
|
+
* older `--experimental-permission`) or any `--allow-*` grant, with or without
|
|
437
|
+
* an `=value`.
|
|
438
|
+
*/
|
|
439
|
+
const PERMISSION_FLAG = /^--(?:experimental-)?permission$|^--allow-[a-z0-9-]+(?:=.*)?$/;
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* `env` with oam's permission flags removed from NODE_OPTIONS, or `env` itself
|
|
443
|
+
* when there is nothing to remove.
|
|
444
|
+
*
|
|
445
|
+
* oam 0.18.0 appends its own `--permission` / `--allow-*` flags to every child's
|
|
446
|
+
* NODE_OPTIONS "for any program" (oam CHANGELOG 0.18.0, Permissions), so a
|
|
447
|
+
* process started under a sandboxed oam carries them in its environment. Node
|
|
448
|
+
* refuses some of them there -- measured on Node 22:
|
|
449
|
+
* `NODE_OPTIONS="--permission --allow-net" node` exits 9 with "--allow-net is
|
|
450
|
+
* not allowed in NODE_OPTIONS" -- and this launcher has no sandbox to pass on
|
|
451
|
+
* (see NO SANDBOX HERE), so the tokens are dropped before a child is started.
|
|
452
|
+
*
|
|
453
|
+
* Only the INHERITED copy can be removed here. When THIS process was itself
|
|
454
|
+
* started as `oam --permission ...`, oam re-appends those flags from its own
|
|
455
|
+
* execArgv inside spawn(), after this env is built -- measured on oam 0.18.0:
|
|
456
|
+
* the Node child still exits 9 with this env. That host is outside what this
|
|
457
|
+
* launcher supports, since the server needs unrestricted net and child-process
|
|
458
|
+
* grants anyway.
|
|
459
|
+
*
|
|
460
|
+
* Tokens are split on whitespace with double-quoted spans kept whole, which is
|
|
461
|
+
* how Node itself reads NODE_OPTIONS. Pure, so src/tests/launcher.test.ts can
|
|
462
|
+
* extract and test it without booting a runtime.
|
|
463
|
+
*/
|
|
464
|
+
function stripPermissionFlags(env) {
|
|
465
|
+
const raw = env.NODE_OPTIONS;
|
|
466
|
+
if (!raw) return env;
|
|
467
|
+
const tokens = raw.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
468
|
+
const kept = tokens.filter((t) => !PERMISSION_FLAG.test(t.replaceAll('"', "")));
|
|
469
|
+
if (kept.length === tokens.length) return env;
|
|
470
|
+
const next = { ...env };
|
|
471
|
+
if (kept.length === 0) delete next.NODE_OPTIONS;
|
|
472
|
+
else next.NODE_OPTIONS = kept.join(" ");
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
|
|
408
476
|
/**
|
|
409
477
|
* Spawn the server in a child runtime and mirror its lifetime.
|
|
410
478
|
*
|
|
@@ -428,7 +496,9 @@ async function launchChild(cmd, args, onLaunchFailed) {
|
|
|
428
496
|
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
429
497
|
// unchanged, and stdin's end propagates to the child.
|
|
430
498
|
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
431
|
-
|
|
499
|
+
// On an oam host, drop the permission flags oam put in NODE_OPTIONS; see
|
|
500
|
+
// stripPermissionFlags. A Node host's env passes through untouched.
|
|
501
|
+
env: process.versions.oam !== undefined ? stripPermissionFlags(process.env) : process.env,
|
|
432
502
|
windowsHide: true,
|
|
433
503
|
});
|
|
434
504
|
} catch (err) {
|