ssh-mcp 2.2.4 → 2.2.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.
@@ -100,8 +100,20 @@ export async function resolveCredentials(profile) {
100
100
  }
101
101
  }
102
102
  if (!creds.password && !creds.privateKey && !creds.agentSocket) {
103
- throw new Error(`No credentials resolved for profile "${profile.name}". ` +
104
- `Available methods: SSH agent (SSH_AUTH_SOCK), OS keychain (auth=keychain), env vars (SSH_MCP_PASSWORD), or key file (SSH_MCP_KEY).`);
103
+ // Naming the method the profile asked for, rather than listing all four,
104
+ // is the difference between "fix the one you chose" and "pick whichever is
105
+ // easiest" — and the easiest is a plaintext password in the environment.
106
+ const asked = profile.auth;
107
+ const why = {
108
+ agent: 'SSH_AUTH_SOCK is unset or the agent holds no usable key',
109
+ key: `no key was read${profile.keyRef ? ` from ${profile.keyRef}` : ' — keyRef is not set on the profile'}`,
110
+ keychain: `the OS keychain returned nothing${profile.keychainEntry ? ` for "${profile.keychainEntry}"` : ' — keychainEntry is not set on the profile'}`,
111
+ password: `SSH_MCP_PASSWORD and SSH_MCP_${profile.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PASSWORD are both unset`,
112
+ };
113
+ throw new Error(`No credentials resolved for profile "${profile.name}", which asks for auth = "${asked}": ${why[asked] ?? 'nothing was found'}.\n` +
114
+ 'Fix that method rather than switching to another if you can — the fallbacks are ordered by how much ' +
115
+ 'they expose. In order: an SSH agent (nothing on disk), an encrypted key with SSH_MCP_KEY, the OS ' +
116
+ `keychain, and last a password in SSH_MCP_PASSWORD, which is readable by every process running as you.`);
105
117
  }
106
118
  return creds;
107
119
  }
@@ -107,7 +107,27 @@ const FORBIDDEN_PATTERNS = [
107
107
  const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff', 'eval']);
108
108
  /** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
109
109
  const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
110
- const PRIVILEGE_PREFIXES = new Set(['sudo', 'su', 'doas', 'pkexec']);
110
+ /**
111
+ * Binaries that run the rest of the command as another user.
112
+ *
113
+ * The list is explicit rather than a pattern, because a pattern cannot tell
114
+ * `sudoedit` — which edits a file and is not elevation — from `sudo-rs`, which
115
+ * is sudo. It has to be maintained by hand as new implementations appear; the
116
+ * cost of missing one is that its commands classify `safe`.
117
+ *
118
+ * The last five were added in 2.2.5 (#132). Until 2.2.4 the check was
119
+ * `/^\s*su\b/` and friends, and `\b` matched between `su` and the hyphen, so
120
+ * `su-exec` and `sudo-rs` were caught — by accident of the regex, not by
121
+ * intent. Moving to exact membership dropped them, which narrowed a security
122
+ * control inside a security release. `gosu` and `run0` were never caught by
123
+ * either form.
124
+ */
125
+ const PRIVILEGE_PREFIXES = new Set([
126
+ 'sudo', 'su', 'doas', 'pkexec',
127
+ // BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
128
+ // distributions, systemd's replacement, and the Solaris/illumos spelling.
129
+ 'su-exec', 'gosu', 'sudo-rs', 'run0', 'pfexec',
130
+ ]);
111
131
  /**
112
132
  * Binaries whose job is to run another command.
113
133
  *
@@ -202,22 +222,57 @@ function parseSegments(command) {
202
222
  * anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
203
223
  * mention rather than an invocation.
204
224
  */
205
- function segmentElevates(words) {
206
- for (const raw of words) {
225
+ function elevatedBinary(words) {
226
+ let i = 0;
227
+ let prefix = null;
228
+ while (i < words.length) {
229
+ const raw = words[i];
207
230
  const word = stripPath(unquote(raw));
208
- if (PRIVILEGE_PREFIXES.has(word))
209
- return true;
210
- if (ASSIGNMENT.test(raw))
211
- continue;
212
- if (EXEC_WRAPPERS.has(word))
213
- continue;
214
- if (raw.startsWith('-'))
231
+ if (PRIVILEGE_PREFIXES.has(word)) {
232
+ prefix ??= word;
233
+ i++;
234
+ // The prefix's own options, some of which swallow the next word.
235
+ while (i < words.length && words[i].startsWith('-')) {
236
+ const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
237
+ i++;
238
+ if (consumesValue)
239
+ i++;
240
+ }
215
241
  continue;
216
- if (BARE_NUMERIC.test(raw))
242
+ }
243
+ // Words that are not yet the command: an assignment, a wrapper, an option,
244
+ // or the bare number `timeout`/`nice` take before theirs.
245
+ if (ASSIGNMENT.test(raw) || EXEC_WRAPPERS.has(word)
246
+ || raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
247
+ i++;
217
248
  continue;
218
- return false;
249
+ }
250
+ // A real command. Before any prefix it means the segment does not elevate —
251
+ // which is what keeps `grep sudo /var/log/auth.log` a mention. After one, it
252
+ // is what actually runs as root.
253
+ return prefix === null ? null : word;
254
+ }
255
+ // `sudo`, or `sudo -u root`, with nothing after it: the prefix is all there is
256
+ // to name.
257
+ return prefix;
258
+ }
259
+ /**
260
+ * The binary this command runs under elevation, or null if it runs none.
261
+ *
262
+ * Returning the name rather than a boolean is what lets `ParsedCommand.binary`
263
+ * describe the same command the class does. Until 2.2.4 a `privileged` class
264
+ * implied a leading prefix, so the anchored `extractBinary` always named the
265
+ * elevated binary; once elevation could be found in any segment that stopped
266
+ * holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
267
+ * privileged decision — in the audit log, the OTel span and OPA's input (#134).
268
+ */
269
+ function elevatedBinaryOf(command) {
270
+ for (const segment of command.split(/[;&|\n]/)) {
271
+ const found = elevatedBinary(segment.trim().split(/\s+/).filter(Boolean));
272
+ if (found !== null)
273
+ return found;
219
274
  }
220
- return false;
275
+ return null;
221
276
  }
222
277
  /**
223
278
  * Does this command elevate anywhere a shell would act on it?
@@ -226,11 +281,6 @@ function segmentElevates(words) {
226
281
  * behind a wrapper, behind an assignment, or after a separator all reached root
227
282
  * with the command classified `safe` or `read-only`.
228
283
  */
229
- function elevates(command) {
230
- return command
231
- .split(/[;&|\n]/)
232
- .some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
233
- }
234
284
  /** An allowlisted binary carrying a flag that makes it write or execute. */
235
285
  function hasDisqualifyingArgs(command) {
236
286
  return parseSegments(command).some(({ head, args }) => {
@@ -355,8 +405,12 @@ export function classifyCommand(command) {
355
405
  const trimmed = command.trim();
356
406
  const binary = extractBinary(trimmed);
357
407
  const fullCommand = trimmed;
358
- if (elevates(trimmed)) {
359
- return { binary, fullCommand, class: 'privileged' };
408
+ // `binary` names the subject of the class. For everything below it is the
409
+ // leading command; here it is the one that runs as root, which are the same
410
+ // thing only when the prefix leads.
411
+ const elevated = elevatedBinaryOf(trimmed);
412
+ if (elevated !== null) {
413
+ return { binary: elevated, fullCommand, class: 'privileged' };
360
414
  }
361
415
  if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
362
416
  return { binary, fullCommand, class: 'destructive' };
@@ -14,7 +14,19 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
14
14
  const stored = knownHosts.get(key);
15
15
  if (stored) {
16
16
  if (stored !== fingerprint) {
17
- throw new Error(`HOST_KEY_MISMATCH: ${host}:${port} key changed (expected ${stored.slice(0, 20)}..., got ${fingerprint.slice(0, 20)}...)`);
17
+ // Two very different things produce this, and the message has to let the
18
+ // reader tell them apart — otherwise the reflex is --insecureHostKey,
19
+ // which turns the check off for every host rather than for this one.
20
+ throw new Error(`HOST_KEY_MISMATCH: the key presented by ${host}:${port} is not the one seen before.\n` +
21
+ ` expected ${stored}\n` +
22
+ ` received ${fingerprint}\n` +
23
+ 'Either the server was rebuilt, reinstalled or had its address reused — in which case the new key is ' +
24
+ 'genuine — or something is intercepting this connection, which is what this check exists to catch. ' +
25
+ 'Nothing here can tell those apart; confirm the fingerprint out of band, from the host itself ' +
26
+ '(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server) or from whoever rebuilt it.\n' +
27
+ 'If it matches, pin it with trustedHostKey on the profile. Do not reach for --insecureHostKey to get ' +
28
+ 'past this: it disables verification for every host and every future connection, including the case ' +
29
+ 'this message is warning you about.');
18
30
  }
19
31
  return true;
20
32
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.2.4",
4
+ "version": "2.2.6",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {