@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 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 stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). 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. |
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 `SSH_MCP_STRICT_HOST_KEY=1`.
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 `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
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
- * The floor is not cosmetic: before 0.9.0 `child_process.execFile` ran its
83
- * arguments through a SHELL, `exec` accepted `timeout` and ignored it,
84
- * `spawnSync` truncated at `maxBuffer` while reporting success, and
85
- * `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server shells
86
- * out to a CLI on its main paths, so those were reachable bugs rather than
87
- * theoretical ones: an argument containing shell metacharacters was re-split
88
- * and executed.
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
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
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 and
145
- * OAM_INSTALL_DIR can pick either.
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
- env: process.env,
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) {