ssh-mcp 2.2.5 → 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
  }
@@ -222,22 +222,57 @@ function parseSegments(command) {
222
222
  * anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
223
223
  * mention rather than an invocation.
224
224
  */
225
- function segmentElevates(words) {
226
- 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];
227
230
  const word = stripPath(unquote(raw));
228
- if (PRIVILEGE_PREFIXES.has(word))
229
- return true;
230
- if (ASSIGNMENT.test(raw))
231
- continue;
232
- if (EXEC_WRAPPERS.has(word))
233
- continue;
234
- 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
+ }
235
241
  continue;
236
- 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++;
237
248
  continue;
238
- 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;
239
274
  }
240
- return false;
275
+ return null;
241
276
  }
242
277
  /**
243
278
  * Does this command elevate anywhere a shell would act on it?
@@ -246,11 +281,6 @@ function segmentElevates(words) {
246
281
  * behind a wrapper, behind an assignment, or after a separator all reached root
247
282
  * with the command classified `safe` or `read-only`.
248
283
  */
249
- function elevates(command) {
250
- return command
251
- .split(/[;&|\n]/)
252
- .some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
253
- }
254
284
  /** An allowlisted binary carrying a flag that makes it write or execute. */
255
285
  function hasDisqualifyingArgs(command) {
256
286
  return parseSegments(command).some(({ head, args }) => {
@@ -375,8 +405,12 @@ export function classifyCommand(command) {
375
405
  const trimmed = command.trim();
376
406
  const binary = extractBinary(trimmed);
377
407
  const fullCommand = trimmed;
378
- if (elevates(trimmed)) {
379
- 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' };
380
414
  }
381
415
  if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
382
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.5",
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": {