ssh-mcp 2.7.0 → 2.8.1

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
@@ -714,8 +714,8 @@ Secrets are **never** passed as CLI arguments.
714
714
  | `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
715
715
  | `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
716
716
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
717
- | `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) — `strict` currently refuses every host |
718
- | `--insecureHostKey` | false | Disable host key verification (test only!) |
717
+ | `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. `strict` accepts only hosts pinned with `trustedHostKey`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) |
718
+ | `--insecureHostKey` | false | Disable host key verification for hosts with no `trustedHostKey` — a pin still refuses (test only!) |
719
719
  | `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
720
720
  | `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
721
721
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
@@ -788,7 +788,8 @@ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts
788
788
  is not consulted, so a restart accepts afresh — see
789
789
  [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
790
790
  explicitly with `trustedHostKey` in a profile, which is the only control here that
791
- survives a restart, or pass `--insecureHostKey` to opt out (test environments only).
791
+ survives a restart — and which no host key mode overrides, so `--insecureHostKey`
792
+ is an opt-out only for hosts you have not pinned (test environments only).
792
793
 
793
794
  ---
794
795
 
@@ -115,7 +115,15 @@ export const profileSchema = z.object({
115
115
  // profile name, and an unrecognised name resolves to the strictest tier.
116
116
  group: z.string().optional(),
117
117
  workdir: z.string().optional(),
118
- trustedHostKey: z.string().optional(),
118
+ // `.trim().min(1)` rather than a bare string. `trustedHostKey = ""` passed
119
+ // validation and then did nothing: the old gate in connection.ts tested it for
120
+ // truthiness, so an empty pin was silently inert and the operator who wrote the
121
+ // line believed they were pinned. A blank pin is always a mistake, and a loud
122
+ // one at startup beats a profile that quietly verifies nothing. The trim is for
123
+ // the same reason in the other direction — the comparison against a fingerprint
124
+ // is exact, so a trailing newline from a heredoc or an editor refused every
125
+ // key with no hint as to why.
126
+ trustedHostKey: z.string().trim().min(1, 'trustedHostKey cannot be empty').optional(),
119
127
  tty: z.boolean().default(false),
120
128
  role: z.string().default('operator'),
121
129
  readOnly: z.boolean().default(false),
@@ -161,11 +161,13 @@ export class SSHConnection {
161
161
  username: this.profile.user,
162
162
  algorithms: FROZEN_ALGORITHMS,
163
163
  hostVerifier: (key) => {
164
- const fp = fingerprintPublicKey(key);
165
- if (this.profile.trustedHostKey && fp !== this.profile.trustedHostKey) {
166
- return false;
167
- }
168
- return verifyHostKey(this.profile.host, this.profile.port, fp, this.knownHostsStore, this.hostKeyMode);
164
+ // The pin used to be checked here, as a `return false` ahead of
165
+ // verifyHostKey. That made it reject-only: a *matching* pin fell
166
+ // through and was refused anyway under `strict`, whose store can never
167
+ // be populated. Passing it in puts one function in charge of the whole
168
+ // decision, and `return false` gave ssh2 a generic handshake failure
169
+ // where verifyHostKey raises a message that names what decided.
170
+ return verifyHostKey(this.profile.host, this.profile.port, fingerprintPublicKey(key), this.knownHostsStore, this.hostKeyMode, this.profile.trustedHostKey);
169
171
  },
170
172
  readyTimeout: 20000,
171
173
  keepaliveInterval: 15000,
@@ -1,5 +1,11 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { OperatorError } from '../errors.js';
3
+ /**
4
+ * Shared as a literal, not through a message builder: the three refusals below
5
+ * diverge in their advice, and message-quality.test.ts asserts each one's wording
6
+ * separately. Only the command is common, and only it should be.
7
+ */
8
+ const CONFIRM_OUT_OF_BAND = '`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server';
3
9
  export function fingerprintPublicKey(key) {
4
10
  const hash = createHash('sha256').update(key).digest('base64');
5
11
  // `={0,2}` rather than `=+`: base64 padding is never longer than two
@@ -8,10 +14,65 @@ export function fingerprintPublicKey(key) {
8
14
  // quantifier is the shape worth not copying elsewhere.
9
15
  return `SHA256:${hash.replace(/={0,2}$/, '')}`;
10
16
  }
11
- export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
17
+ /**
18
+ * Decide whether to accept the key a host just presented.
19
+ *
20
+ * `knownHosts` is one Map shared by every profile (connection-registry.ts), which
21
+ * is why a pin never *reads* it: consulting it for a pinned host would make two
22
+ * profiles pinning different keys for one host:port collide with a false
23
+ * HOST_KEY_MISMATCH, once the host presents each its own key.
24
+ *
25
+ * Why `--hostKeyMode=strict` refused every host before 2.8.0 is in
26
+ * .changeset/lucky-keys-pinned.md and SECURITY.md; the branch order here is what
27
+ * that history left behind.
28
+ *
29
+ * @param pinnedFingerprint `trustedHostKey` from the profile. Falsy means no pin.
30
+ * A value decides the outcome in every mode, `insecure` included. It is never
31
+ * compared against `knownHosts`, but a match does record into it under `tofu` —
32
+ * see the pin branch for why those two differ.
33
+ */
34
+ export function verifyHostKey(host, port, fingerprint, knownHosts, mode, pinnedFingerprint) {
35
+ const key = `${host}:${port}`;
36
+ // Before `insecure`, not after: the gate this replaced sat ahead of
37
+ // verifyHostKey in connection.ts, so a key contradicting the pin was refused in
38
+ // every mode, `insecure` included. Pinned by host-key.test.ts, "refuses a key
39
+ // that contradicts the pin, in every mode including insecure".
40
+ //
41
+ // Truthiness, not `!== undefined`: `trustedHostKey = ""` was inert on 2.7.0
42
+ // because that gate tested truthiness, and `!== undefined` would turn the same
43
+ // config into "refuse every host". The schema rejects a blank pin, so this is
44
+ // defence in depth rather than the primary guard.
45
+ if (pinnedFingerprint) {
46
+ if (pinnedFingerprint !== fingerprint) {
47
+ // No "or turn the check off" exit here, unlike the two refusals below.
48
+ // Nothing overrides a pin — not tofu, not insecure — so offering a mode
49
+ // would describe a way out that does not exist.
50
+ throw new OperatorError(`HOST_KEY_PIN_MISMATCH: the key presented by ${host}:${port} is not the pinned key.\n` +
51
+ ` pinned ${pinnedFingerprint}\n` +
52
+ ` received ${fingerprint}\n` +
53
+ 'trustedHostKey on this profile is what decided, and no host key mode overrides it. ' +
54
+ 'Either the pin is stale — the server was rebuilt or its address reused, in which case ' +
55
+ `confirm the new fingerprint out of band (${CONFIRM_OUT_OF_BAND}) ` +
56
+ 'and update trustedHostKey — or something is intercepting this connection, ' +
57
+ 'which is what pinning exists to catch.');
58
+ }
59
+ // Matched. Two constraints on the write, neither derivable from the code:
60
+ //
61
+ // `tofu` only — under `strict` the store is the only thing that admits an
62
+ // *unpinned* profile, so seeding it there would let one profile's pin authorise
63
+ // another. Fill-only — an unconditional set makes the entry order-dependent for
64
+ // an unpinned sibling, and 2.7.0's write was reachable only on a store miss, so
65
+ // this writes exactly when that one would have.
66
+ //
67
+ // And it is inside the match, below the throw: recording on the refusal path
68
+ // would put an interceptor's fingerprint into the shared store, which is the
69
+ // failure the write exists to prevent.
70
+ if (mode === 'tofu' && !knownHosts.has(key))
71
+ knownHosts.set(key, fingerprint);
72
+ return true;
73
+ }
12
74
  if (mode === 'insecure')
13
75
  return true;
14
- const key = `${host}:${port}`;
15
76
  const stored = knownHosts.get(key);
16
77
  if (stored) {
17
78
  if (stored !== fingerprint) {
@@ -24,7 +85,7 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
24
85
  'Either the server was rebuilt, reinstalled or had its address reused — in which case the new key is ' +
25
86
  'genuine — or something is intercepting this connection, which is what this check exists to catch. ' +
26
87
  'Nothing here can tell those apart; confirm the fingerprint out of band, from the host itself ' +
27
- '(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server) or from whoever rebuilt it.\n' +
88
+ `(${CONFIRM_OUT_OF_BAND}) or from whoever rebuilt it.\n` +
28
89
  'If it matches, pin it with trustedHostKey on the profile. Do not reach for --insecureHostKey to get ' +
29
90
  'past this: it disables verification for every host and every future connection, including the case ' +
30
91
  'this message is warning you about.');
@@ -16,7 +16,11 @@ export function registerFileTools({ server }, pipeline) {
16
16
  await new SftpClient(rt.conn).upload({ remotePath, content });
17
17
  return {
18
18
  audited: syntheticSuccess(rt.profileName),
19
- output: textResult(`Uploaded ${content.length} bytes to ${remotePath}`),
19
+ // Byte count, not string length: `content` is a JS string, so
20
+ // `.length` counts UTF-16 code units and under-reports every
21
+ // multi-byte character. SftpClient.upload writes Buffer.from(content),
22
+ // which is utf8, so this is exactly what lands on the remote side.
23
+ output: textResult(`Uploaded ${Buffer.byteLength(content, 'utf8')} bytes to ${remotePath}`),
20
24
  };
21
25
  });
22
26
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.7.0",
4
+ "version": "2.8.1",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",