ssh-mcp 2.6.0 → 2.8.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
@@ -487,7 +487,17 @@ When `--opaUrl` is set, commands the built-in engine allows are additionally
487
487
  evaluated by OPA. **OPA can only narrow.** A command the built-in engine has
488
488
  already denied returns that denial without OPA being consulted at all, so a
489
489
  sidecar answering `allow` cannot grant a class the role bindings withhold. To
490
- widen, edit `[policy]`. The request shape follows the AuthZEN Access Evaluation contract:
490
+ widen, edit `[policy]`.
491
+
492
+ An outage falls back to the local decision and logs one warning per minute. That is the
493
+ default because OPA is an *additional* deny layer and stopping all work would be the worse
494
+ failure — but an operator who deployed OPA *as* the authorization gate loses that gate
495
+ during the outage, and the only signal is a stderr line MCP clients usually discard.
496
+ `--opaFailClosed` makes the gate being down mean no; the refusal carries `ruleId:
497
+ "opa-unavailable"` so the audit record says the gate was down rather than implying a policy
498
+ refused the command.
499
+
500
+ The request shape follows the AuthZEN Access Evaluation contract:
491
501
 
492
502
  ```json
493
503
  {
@@ -500,7 +510,7 @@ widen, edit `[policy]`. The request shape follows the AuthZEN Access Evaluation
500
510
  }
501
511
  ```
502
512
 
503
- OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands (fail-open to avoid locking out access).
513
+ OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands by default (fail-open, to avoid locking out access); `--opaFailClosed` refuses instead. A 200 that carries no boolean `result` counts as unreachable — that is what OPA answers for an undefined document, so a misnamed package or an unactivated bundle is an outage rather than consent.
504
514
 
505
515
  Example Rego policy (`ssh-mcp.rego`):
506
516
  ```rego
@@ -618,6 +628,9 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
618
628
  | `--httpPort` | 3000 | HTTP listen port |
619
629
  | `--httpHost` | 127.0.0.1 | Bind address |
620
630
  | `--rateLimit` | 0 (off) | Max requests per minute (0 = unlimited) |
631
+ | `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
632
+ | `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
633
+ | `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
621
634
 
622
635
  Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
623
636
 
@@ -629,6 +642,25 @@ bearer token.
629
642
 
630
643
  When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
631
644
 
645
+ Failed authentication is throttled separately, and on by default. `--rateLimit` never saw a
646
+ wrong bearer token, because the auth check answers before the limiter is reached — so
647
+ guessing ran at network speed. `--authFailureLimit` gives each client its own small budget,
648
+ spent only on a 401; a correct token never consumes from it, so a working client never
649
+ throttles itself. Once an address has spent its budget every request from it waits,
650
+ including one with the right token — that is deliberate, since answering the guess would
651
+ otherwise tell the caller which token was right. Clients are told apart by socket address. **Behind a
652
+ reverse proxy that means every client shares one budget**, so set `--trustProxy` when the
653
+ proxy is yours — the server prints a warning the first time it sees `X-Forwarded-For`
654
+ without it. `--trustProxy` takes the *rightmost* `X-Forwarded-For` entry, which is the hop
655
+ the proxy itself appended; everything to its left came from the client, so reading the
656
+ leftmost would let a client choose its own budget or spend a victim's. That only holds if a
657
+ proxy actually appended the entry, so the header is read **only when the peer is the
658
+ proxy** — bare `--trustProxy` means a loopback peer, which is the deployment above; name a
659
+ proxy elsewhere with `--trustedProxies`. When the header cannot be read as an address, or
660
+ the peer is not trusted, the server falls back to the socket address and says so once, so
661
+ a flag that is not taking effect is not silent. One trusted hop is assumed. A malformed `--authFailureLimit` is refused at startup rather than silently
662
+ disabling the check; only `0` turns it off.
663
+
632
664
  **Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
633
665
 
634
666
  ---
@@ -678,13 +710,18 @@ Secrets are **never** passed as CLI arguments.
678
710
  | `--httpHost` | 127.0.0.1 | HTTP bind address |
679
711
  | `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
680
712
  | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
713
+ | `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
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
+ | `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
681
716
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
682
- | `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) — `strict` currently refuses every host |
683
- | `--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!) |
684
719
  | `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
685
720
  | `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
686
721
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
687
722
  | `--opaUrl` | — | OPA sidecar URL for external policy |
723
+ | `--opaFailClosed` | false | Refuse every command while OPA is unreachable, instead of falling back to local policy |
724
+ | `--opaTimeoutMs` | 10000 | How long to wait for the OPA sidecar. Lower makes the fail-open cheaper to reach; higher makes an outage slower to notice |
688
725
  | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
689
726
  | `--approvalGrantTtl` | 0 (off) | Auto-approve an identical command for this many ms after approval |
690
727
  | `--auditEntropyScan` | false | Enable entropy-based secret scanning in audit |
@@ -751,7 +788,8 @@ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts
751
788
  is not consulted, so a restart accepts afresh — see
752
789
  [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
753
790
  explicitly with `trustedHostKey` in a profile, which is the only control here that
754
- 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).
755
793
 
756
794
  ---
757
795
 
package/build/cli.js CHANGED
@@ -59,6 +59,35 @@ export function parseArgv(args = process.argv.slice(2)) {
59
59
  * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
60
60
  * turned off once written into a wrapper script is its own annoyance.
61
61
  */
62
+ /**
63
+ * `--authFailureLimit`, refusing rather than guessing.
64
+ *
65
+ * `parseArgv` stores `null` for a flag written without a value, and `null !== undefined`,
66
+ * so a bare `--authFailureLimit` reached `parseInt` and produced NaN. NaN fails the `> 0`
67
+ * test the transport uses to decide whether to build the limiter at all, so a typo — or
68
+ * `--authFailureLimit=off`, which `0 = off` makes a plausible spelling — silently turned
69
+ * off a control that is on by default, with no line in the startup banner to say so. The
70
+ * same shape on `--rateLimit` is harmless because that one defaults to off; this one does
71
+ * not, which is what makes failing open new.
72
+ */
73
+ export function parseFailureLimit(raw) {
74
+ if (raw === undefined)
75
+ return undefined;
76
+ if (raw === null || !/^\d+$/.test(raw.trim())) {
77
+ throw new OperatorError(`--authFailureLimit needs a non-negative whole number, got ${JSON.stringify(raw)}. ` +
78
+ 'Use --authFailureLimit=0 to turn the check off deliberately.');
79
+ }
80
+ const value = Number(raw.trim());
81
+ // Bounded, because an unbounded one is the same fail-open this guard exists to stop:
82
+ // `--authFailureLimit=99999999999999999999` parsed to 1e20, a bucket that never empties
83
+ // and a `Retry-After` of 1 — configured on, functionally absent.
84
+ if (!Number.isSafeInteger(value) || value > MAX_AUTH_FAILURE_LIMIT) {
85
+ throw new OperatorError(`--authFailureLimit must be between 0 and ${MAX_AUTH_FAILURE_LIMIT}, got ${JSON.stringify(raw)}.`);
86
+ }
87
+ return value;
88
+ }
89
+ /** Far above any real budget; the point is that there is one. */
90
+ const MAX_AUTH_FAILURE_LIMIT = 10_000;
62
91
  export function flagEnabled(argv, name) {
63
92
  if (!(name in argv))
64
93
  return false;
@@ -99,6 +128,53 @@ export function parseMaxChars(raw) {
99
128
  return 5_000;
100
129
  return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
101
130
  }
131
+ /**
132
+ * `--opaTimeoutMs`, bounded on both sides.
133
+ *
134
+ * Too low turns an explicit OPA deny into an allow whenever the sidecar is slow, which is
135
+ * the fail-open this budget exists inside; too high brings back the hang it replaced. One
136
+ * second is the floor and two minutes the ceiling.
137
+ */
138
+ export function parseOpaTimeout(raw) {
139
+ if (raw === undefined)
140
+ return undefined;
141
+ const value = raw === null ? NaN : Number(raw.trim());
142
+ if (!Number.isSafeInteger(value) || value < 1_000 || value > 120_000) {
143
+ throw new OperatorError(`--opaTimeoutMs must be a whole number of milliseconds between 1000 and 120000, got ${JSON.stringify(raw)}.`);
144
+ }
145
+ return value;
146
+ }
147
+ /**
148
+ * `--opaUrl`, refused at startup rather than at every request.
149
+ *
150
+ * Only emptiness was checked, so anything `fetch` cannot use reached the engine and failed
151
+ * on every call — with `OPA sidecar enabled` printed at startup and one stderr line per
152
+ * minute as the only signal. `--opaUrl=localhost:8181` is a realistic slip, since OPA's own
153
+ * `--addr` is written `:8181`, and in the default mode it leaves a gate that looks
154
+ * configured and is never consulted. Credentials in the URL are refused too: `fetch`
155
+ * rejects such a URL outright, so it would be a gate that cannot work at all.
156
+ */
157
+ export function parseOpaUrl(raw) {
158
+ if (!raw) {
159
+ throw new OperatorError('--opaUrl needs a URL, as --opaUrl=http://localhost:8181.');
160
+ }
161
+ let parsed;
162
+ try {
163
+ parsed = new URL(raw);
164
+ }
165
+ catch {
166
+ throw new OperatorError(`--opaUrl=${raw} is not a URL. Include the scheme, as --opaUrl=http://localhost:8181.`);
167
+ }
168
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
169
+ throw new OperatorError(`--opaUrl must be http or https, got ${parsed.protocol}`);
170
+ }
171
+ if (parsed.username || parsed.password) {
172
+ throw new OperatorError('--opaUrl must not embed credentials — fetch() refuses such a URL, so the gate would ' +
173
+ 'never be consulted. Put the sidecar behind a network boundary instead.');
174
+ }
175
+ // A trailing slash would make the request path `//v1/data/...`.
176
+ return (parsed.origin + parsed.pathname).replace(/\/$/, '');
177
+ }
102
178
  /**
103
179
  * `strict` was previously unreachable: nothing but test code could select it,
104
180
  * yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
@@ -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),
package/build/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { reportFatal } from './errors.js';
4
+ import { reportFatal, OperatorError } from './errors.js';
5
5
  import { ConnectionRegistry } from './ssh/connection-registry.js';
6
6
  import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
7
7
  import { AuditStore } from './audit/store.js';
8
8
  import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
9
9
  import { initKeychain } from './config/credential-resolver.js';
10
10
  import { SERVER_VERSION } from './version.js';
11
- import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
11
+ import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, parseFailureLimit, parseOpaUrl, parseOpaTimeout, resolveHostKeyMode, } from './cli.js';
12
12
  async function main() {
13
13
  const argv = parseArgv();
14
14
  checkRemovedFlags(argv);
@@ -28,10 +28,16 @@ async function main() {
28
28
  const registry = new ConnectionRegistry(config, hostKeyMode);
29
29
  const policy = new PolicyEngine(resolvePolicyRules(config.profiles, config.policy));
30
30
  const audit = new AuditStore(undefined, entropyScan, tamperEvident);
31
- if (argv.opaUrl) {
32
- policy.setOpaUrl(argv.opaUrl);
31
+ if ('opaUrl' in argv) {
32
+ // Presence, not truthiness — the trap `flagEnabled` was written for. A
33
+ // space-separated `--opaUrl http://…` stores null, so the old guard dropped it
34
+ // silently, and dropped any `--opaFailClosed` the operator asked for with it.
35
+ policy.setOpaUrl(parseOpaUrl(argv.opaUrl), flagEnabled(argv, 'opaFailClosed'), parseOpaTimeout(argv.opaTimeoutMs));
33
36
  console.error(`OPA sidecar enabled: ${argv.opaUrl}`);
34
37
  }
38
+ else if (flagEnabled(argv, 'opaFailClosed')) {
39
+ throw new OperatorError('--opaFailClosed does nothing without --opaUrl.');
40
+ }
35
41
  // An McpServer binds to a single transport, so HTTP needs one per session.
36
42
  const createMcpServer = () => {
37
43
  // capabilities moved from serverInfo to ServerOptions in SDK 1.30.
@@ -50,6 +56,9 @@ async function main() {
50
56
  host: argv.httpHost || '127.0.0.1',
51
57
  bearerToken: argv.bearerToken,
52
58
  rateLimit: parseInt(argv.rateLimit) || 0,
59
+ authFailureLimit: parseFailureLimit(argv.authFailureLimit),
60
+ trustProxy: flagEnabled(argv, 'trustProxy'),
61
+ trustedProxies: argv.trustedProxies?.split(',').map((h) => h.trim()).filter(Boolean),
53
62
  allowedHosts: argv.allowedHosts?.split(',').map((h) => h.trim()).filter(Boolean),
54
63
  registry,
55
64
  });
@@ -82,7 +82,19 @@ const FORBIDDEN_PATTERNS = [
82
82
  /rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
83
83
  /mkfs\./,
84
84
  />\s*\/dev\/sd/,
85
- /:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
85
+ // Fork bomb, with whitespace allowed at the five positions bash allows it and the old
86
+ // pattern did not: before the parentheses, inside them, either side of the pipe, and
87
+ // before the ampersand. `: () { : | : & } ; :` ran and classified `safe`.
88
+ //
89
+ // What this still does not catch, so that nobody reads it as "fork bombs are handled":
90
+ // the same bomb under another name (`f(){ f|f& };f`), a body that separates the two
91
+ // calls with `&` or `;` rather than `|` (`:(){ :&:& };:`), and a comment between the
92
+ // tokens (`:() # x\n{ :|:& };:`). Matching the first needs a backreference over an
93
+ // unbounded body and this file has already shipped one ReDoS; the others would widen a
94
+ // list no role, tier or approval mode can override. The impact is a denial of service
95
+ // against the target host rather than against this server, which is not worth either
96
+ // trade — but it is a hole, not a closed door.
97
+ /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
86
98
  />\s*\/etc\/cron/,
87
99
  />\s*\/etc\/systemd/,
88
100
  />\s*~\/.ssh\/authorized_keys/,
@@ -1,5 +1,17 @@
1
1
  import { classifyCommand, findForbiddenMatch } from './classifier.js';
2
2
  import { OperatorError } from '../errors.js';
3
+ /**
4
+ * How long to wait for the OPA sidecar before treating it as unavailable.
5
+ *
6
+ * Bounded because an unbounded wait held every tool call for undici's five-minute header
7
+ * timeout. Ten seconds and not two: in the default mode a timeout falls back to the local
8
+ * decision, so the budget is also the price of turning an explicit OPA *deny* into an
9
+ * allow — at two seconds a sidecar under load crosses it by accident, and anyone who can
10
+ * add latency crosses it on purpose. Ten is above a loaded sidecar's p99 and still thirty
11
+ * times cheaper than the hang it replaced. `--opaTimeoutMs` moves it: lower makes the
12
+ * fail-open cheaper to reach, higher makes an outage slower to notice.
13
+ */
14
+ const DEFAULT_OPA_TIMEOUT_MS = 10_000;
3
15
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
4
16
  export const HOST_GROUPS = ['prod', 'staging', 'dev'];
5
17
  export const DEFAULT_RULES = {
@@ -184,6 +196,8 @@ export function resolvePolicyRules(profiles, override) {
184
196
  export class PolicyEngine {
185
197
  rules;
186
198
  opaUrl = null;
199
+ opaFailClosed = false;
200
+ opaTimeoutMs = DEFAULT_OPA_TIMEOUT_MS;
187
201
  /** Rate-limits the fail-open warning so one outage can't flood stderr. */
188
202
  lastOpaWarning = 0;
189
203
  /** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
@@ -202,8 +216,15 @@ export class PolicyEngine {
202
216
  }
203
217
  });
204
218
  }
205
- setOpaUrl(url) {
219
+ setOpaUrl(url, failClosed = false, timeoutMs = DEFAULT_OPA_TIMEOUT_MS) {
220
+ this.opaTimeoutMs = timeoutMs;
221
+ // Reset the warning throttle when the mode changes, or the one warning per minute can
222
+ // be the *previous* mode's sentence — "commands OPA would deny may now be allowed"
223
+ // printed while the engine refuses everything.
224
+ if (failClosed !== this.opaFailClosed || url !== this.opaUrl)
225
+ this.lastOpaWarning = 0;
206
226
  this.opaUrl = url;
227
+ this.opaFailClosed = failClosed;
207
228
  }
208
229
  evaluate(command, profile, _toolName) {
209
230
  const parsed = classifyCommand(command);
@@ -277,13 +298,31 @@ export class PolicyEngine {
277
298
  method: 'POST',
278
299
  headers: { 'Content-Type': 'application/json' },
279
300
  body: JSON.stringify({ input }),
301
+ // A sidecar that accepts the connection and never answers is the canonical way
302
+ // OPA is "unreachable", and without this the call ran to undici's five-minute
303
+ // header timeout while every tool call waited on it. The abort lands in the catch
304
+ // below, so both modes reach their own answer inside the budget.
305
+ signal: AbortSignal.timeout(this.opaTimeoutMs),
280
306
  });
281
307
  if (!resp.ok) {
282
- this.warnOpaUnavailable(`HTTP ${resp.status} from ${this.opaUrl}`);
283
- return local;
308
+ return this.onOpaUnavailable(`HTTP ${resp.status}`, local);
284
309
  }
285
- const data = await resp.json();
286
- if (data.result === false) {
310
+ const payload = await resp.json();
311
+ // Guarded, not cast. `as { result?: unknown }` is a lie for a `null` body — reading
312
+ // `.result` off it threw, so the cause on stderr read as a defect in this server
313
+ // rather than as the "no decision" condition it names below.
314
+ const result = typeof payload === 'object' && payload !== null
315
+ ? payload.result
316
+ : undefined;
317
+ if (typeof result !== 'boolean') {
318
+ // A 200 carrying no decision is how OPA answers for an undefined document, which
319
+ // is what a wrong package path, an unactivated bundle, or an `allow` rule written
320
+ // without `default allow := false` all produce. Reading that as consent meant
321
+ // `--opaFailClosed` did not cover the way an OPA gate actually goes down: six of
322
+ // seven response shapes allowed, with no warning at all.
323
+ return this.onOpaUnavailable('no boolean `result` in the response', local);
324
+ }
325
+ if (result === false) {
287
326
  return {
288
327
  decision: 'deny',
289
328
  commandClass: parsed.class,
@@ -294,10 +333,36 @@ export class PolicyEngine {
294
333
  }
295
334
  }
296
335
  catch (err) {
297
- this.warnOpaUnavailable(err instanceof Error ? err.message : String(err));
336
+ return this.onOpaUnavailable(err instanceof Error ? err.message : String(err), local);
298
337
  }
299
338
  return local;
300
339
  }
340
+ /**
341
+ * What an OPA outage means, which is the operator's choice rather than ours.
342
+ *
343
+ * Falling back to the local decision is the default and stays the default: OPA is an
344
+ * additional deny layer, and an outage that stopped all work would be a worse failure
345
+ * than one that narrows the policy. But an operator who deployed OPA *as* the
346
+ * authorization gate has a control that disappears during an outage, and the only signal
347
+ * is a stderr line that MCP clients usually discard. `--opaFailClosed` lets them say
348
+ * that the gate being down means no.
349
+ */
350
+ onOpaUnavailable(cause, local) {
351
+ this.warnOpaUnavailable(cause);
352
+ if (!this.opaFailClosed)
353
+ return local;
354
+ return {
355
+ decision: 'deny',
356
+ commandClass: local.commandClass,
357
+ binary: local.binary,
358
+ ruleId: 'opa-unavailable',
359
+ // Fixed string. `cause` goes to stderr, where the operator is; this `reason` is
360
+ // rethrown as the tool's error text and written to the audit record, and it carried
361
+ // `this.opaUrl` — an internal hostname, and any credential embedded in it — out to
362
+ // the MCP client on every refusal.
363
+ reason: 'OPA evaluation unavailable and --opaFailClosed is set',
364
+ };
365
+ }
301
366
  /**
302
367
  * OPA is an *additional* deny layer, so an outage falls back to the local
303
368
  * decision rather than blocking all work. That is a deliberate trade-off, but
@@ -311,7 +376,9 @@ export class PolicyEngine {
311
376
  return;
312
377
  this.lastOpaWarning = now;
313
378
  console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
314
- 'Falling back to local policy — commands OPA would deny may now be allowed.');
379
+ (this.opaFailClosed
380
+ ? 'Refusing every command while it is down, because --opaFailClosed is set.'
381
+ : 'Falling back to local policy — commands OPA would deny may now be allowed.'));
315
382
  }
316
383
  /**
317
384
  * Say which three things produced the refusal, not one of them.
@@ -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.');
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'http';
2
2
  import { randomUUID, timingSafeEqual } from 'crypto';
3
+ import { isIP } from 'net';
3
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
5
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
6
  import { SERVER_VERSION } from '../version.js';
@@ -7,32 +8,193 @@ import { OperatorError } from '../errors.js';
7
8
  const MAX_BODY_SIZE = 1_048_576; // 1MB
8
9
  /** Cap on concurrent MCP sessions, so unauthenticated-adjacent churn can't grow the map without bound. */
9
10
  const MAX_SESSIONS = 64;
11
+ /** How many failed auth attempts one client may make per minute. 0 disables the check. */
12
+ export const DEFAULT_AUTH_FAILURE_LIMIT = 10;
13
+ /** Cap on tracked clients, for the reason `MAX_SESSIONS` exists: churn must not grow a map. */
14
+ export const MAX_TRACKED_CLIENTS = 1024;
15
+ const REFILL_INTERVAL_MS = 60_000;
16
+ /**
17
+ * One token-bucket step, shared by the two limiters so the refill arithmetic exists once.
18
+ *
19
+ * Mutates the bucket. Refills proportionally to elapsed time rather than on a timer, so an
20
+ * idle server costs nothing and there is no interval to clean up.
21
+ */
22
+ function consume(bucket, maxTokens) {
23
+ const elapsed = Date.now() - bucket.lastRefill;
24
+ const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * maxTokens);
25
+ if (refilled > 0) {
26
+ bucket.tokens = Math.min(maxTokens, bucket.tokens + refilled);
27
+ bucket.lastRefill += Math.round((refilled / maxTokens) * REFILL_INTERVAL_MS);
28
+ }
29
+ if (bucket.tokens > 0) {
30
+ bucket.tokens--;
31
+ return { allowed: true, retryAfterMs: 0 };
32
+ }
33
+ return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / maxTokens) };
34
+ }
10
35
  class RateLimiter {
11
- tokens;
12
- lastRefill;
13
36
  maxTokens;
14
- refillIntervalMs;
15
- constructor(maxRequestsPerMinute) {
16
- this.maxTokens = maxRequestsPerMinute;
17
- this.tokens = maxRequestsPerMinute;
18
- this.lastRefill = Date.now();
19
- this.refillIntervalMs = 60_000;
37
+ bucket;
38
+ constructor(maxTokens) {
39
+ this.maxTokens = maxTokens;
40
+ this.bucket = { tokens: maxTokens, lastRefill: Date.now() };
20
41
  }
21
42
  tryConsume() {
22
- const now = Date.now();
23
- const elapsed = now - this.lastRefill;
24
- const refilled = Math.floor((elapsed / this.refillIntervalMs) * this.maxTokens);
25
- if (refilled > 0) {
26
- this.tokens = Math.min(this.maxTokens, this.tokens + refilled);
27
- this.lastRefill += Math.round((refilled / this.maxTokens) * this.refillIntervalMs);
28
- }
29
- if (this.tokens > 0) {
30
- this.tokens--;
43
+ return consume(this.bucket, this.maxTokens);
44
+ }
45
+ }
46
+ /**
47
+ * A bucket per client, for throttling failed authentication.
48
+ *
49
+ * Separate from the request limiter on purpose. The auth check returned before the request
50
+ * limiter was reached, so a wrong bearer token consumed nothing and guessing ran at network
51
+ * speed with no backoff — measured as twelve 401s and zero 429s against `--rateLimit=3`.
52
+ * Moving the request limiter above the auth check would have closed that and opened
53
+ * something worse: the request bucket is global, so unauthenticated traffic could then
54
+ * starve every legitimate client.
55
+ *
56
+ * Only failures consume a token, so a working client never builds a budget up and is never
57
+ * throttled by its own traffic — which is what makes this safe to have on by default. It
58
+ * is not a promise that a correct token always passes: once an address has spent its
59
+ * budget, everything from that address waits, correct tokens included. That is the whole
60
+ * point. Charging only after the comparison would leave the guess itself evaluated and the
61
+ * status code would still tell the attacker which token was right.
62
+ */
63
+ export class AuthFailureLimiter {
64
+ maxTokens;
65
+ buckets = new Map();
66
+ constructor(maxTokens) {
67
+ this.maxTokens = maxTokens;
68
+ }
69
+ /** Whether this client may make another attempt. Does not consume. */
70
+ peek(key) {
71
+ const bucket = this.buckets.get(key);
72
+ if (bucket === undefined)
73
+ return { allowed: true, retryAfterMs: 0 };
74
+ const elapsed = Date.now() - bucket.lastRefill;
75
+ const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * this.maxTokens);
76
+ const available = Math.min(this.maxTokens, bucket.tokens + Math.max(refilled, 0));
77
+ if (available > 0)
31
78
  return { allowed: true, retryAfterMs: 0 };
79
+ return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / this.maxTokens) };
80
+ }
81
+ /** Charge this client for a failed attempt. */
82
+ recordFailure(key) {
83
+ let bucket = this.buckets.get(key);
84
+ if (bucket === undefined) {
85
+ bucket = { tokens: this.maxTokens, lastRefill: Date.now() };
86
+ if (this.buckets.size >= MAX_TRACKED_CLIENTS) {
87
+ // Evict the *fullest* entry, not the oldest. An exhausted bucket has the oldest
88
+ // `lastRefill` by construction, so evicting by age evicted the locked-out client
89
+ // first and then handed its key a fresh budget: minting enough keys cleared a
90
+ // lockout, which was measured end to end. A full bucket is the one with nothing
91
+ // worth remembering.
92
+ let fullestKey = null;
93
+ let fullest = -1;
94
+ for (const [k, b] of this.buckets) {
95
+ if (b.tokens > fullest) {
96
+ fullest = b.tokens;
97
+ fullestKey = k;
98
+ }
99
+ }
100
+ if (fullestKey !== null)
101
+ this.buckets.delete(fullestKey);
102
+ // Every tracked client is spent, so the table itself is the signal and a new key
103
+ // does not get a full budget.
104
+ //
105
+ // The cost, stated because it is real and cannot be softened here: while the table
106
+ // is saturated an arriving client has one attempt rather than the full budget, so
107
+ // a first typo leaves its correct token waiting. Seeding one token instead of zero
108
+ // looks like it would help and does not — `consume` spends it immediately, and
109
+ // `peek` already allows a key it has never seen, so the first attempt is free
110
+ // either way and the second is refused either way. Measured both, identical.
111
+ // Giving an arriving key a real budget is the refund this rule exists to stop.
112
+ // Saturation needs 1024 addresses that have each spent a full budget.
113
+ if (fullest <= 0)
114
+ bucket = { tokens: 0, lastRefill: Date.now() };
115
+ }
116
+ this.buckets.set(key, bucket);
32
117
  }
33
- const retryAfterMs = Math.ceil(this.refillIntervalMs / this.maxTokens);
34
- return { allowed: false, retryAfterMs };
118
+ consume(bucket, this.maxTokens);
119
+ }
120
+ }
121
+ /**
122
+ * Which client an attempt is charged to.
123
+ *
124
+ * The socket's remote address, which is the real client on a direct connection — how this
125
+ * server is normally run. `X-Forwarded-For` is read only when a proxy is explicitly
126
+ * trusted: honouring it unconditionally would let any client forge its own key and so opt
127
+ * out of the limit entirely.
128
+ *
129
+ * When it is read, the **rightmost** entry is the one taken, not the leftmost. A proxy
130
+ * appends the address it saw, so the rightmost entry is what the nearest trusted proxy
131
+ * observed while every entry to its left came from the client. Reading the leftmost was
132
+ * worse than having no limit: a client sending `X-Forwarded-For: 10.0.0.1` minted a fresh
133
+ * budget per request — measured as nine wrong tokens and zero 429s — and sending a
134
+ * victim's address burned *their* budget instead, locking out a correct token.
135
+ *
136
+ * This assumes exactly one trusted proxy in front, which is what `--trustProxy` means. A
137
+ * chain of two would need the second-from-right, and this does not try to guess the depth.
138
+ * Anything that is not an IP address is discarded rather than used as a map key.
139
+ */
140
+ export function clientKey(req, trustProxy, trustedProxies) {
141
+ const peer = canonicalAddress(req.socket.remoteAddress ?? 'unknown');
142
+ const forwarded = req.headers['x-forwarded-for'];
143
+ if (!trustProxy || forwarded === undefined)
144
+ return { key: peer, forwardedIgnored: false };
145
+ // The rightmost entry is proxy-authored only if a proxy actually appended one. Nothing
146
+ // about the header says whether it did, so the *peer* has to be the proxy — otherwise a
147
+ // client reaching the listener directly sends one forged entry, that entry is the
148
+ // rightmost, and it picks its own key. Both attacks this keying was fixed to stop came
149
+ // back alive in exactly that configuration.
150
+ if (!isTrustedPeer(peer, trustedProxies)) {
151
+ return { key: peer, forwardedIgnored: true };
152
+ }
153
+ const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded;
154
+ const entries = raw.split(',').map((e) => e.trim()).filter(Boolean);
155
+ const nearest = entries[entries.length - 1];
156
+ const address = nearest === undefined ? undefined : forwardedAddress(nearest);
157
+ if (address === undefined)
158
+ return { key: peer, forwardedIgnored: true };
159
+ return { key: address, forwardedIgnored: false };
160
+ }
161
+ /**
162
+ * Whether the peer may speak for other clients.
163
+ *
164
+ * Bare `--trustProxy` means a loopback peer, which is the deployment the README asks for:
165
+ * the server binds to 127.0.0.1 and a proxy on the same host terminates TLS. A proxy
166
+ * somewhere else has to be named, because "trust whoever connected" is the assumption that
167
+ * made the header forgeable again.
168
+ */
169
+ function isTrustedPeer(peer, trustedProxies) {
170
+ if (trustedProxies !== undefined && trustedProxies.length > 0) {
171
+ return trustedProxies.includes(peer);
172
+ }
173
+ return peer === '127.0.0.1' || peer === '::1' || peer.startsWith('127.');
174
+ }
175
+ /**
176
+ * One `X-Forwarded-For` entry as an address, or undefined if it is not one.
177
+ *
178
+ * `net.isIP` rejects the bracketed and port-suffixed spellings, which is how IPv6 usually
179
+ * appears in this header — and rejecting silently collapsed every client onto the proxy's
180
+ * key, which is worse than not keying at all.
181
+ */
182
+ function forwardedAddress(entry) {
183
+ let candidate = entry;
184
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(candidate);
185
+ if (bracketed)
186
+ candidate = bracketed[1];
187
+ // A trailing `:port` only when what is left is not itself an IPv6 literal: `::1` has
188
+ // colons of its own and must not be truncated.
189
+ else if (isIP(candidate) === 0 && /^[^:]+:\d+$/.test(candidate)) {
190
+ candidate = candidate.slice(0, candidate.lastIndexOf(':'));
35
191
  }
192
+ candidate = canonicalAddress(candidate);
193
+ return isIP(candidate) === 0 ? undefined : candidate;
194
+ }
195
+ /** `::ffff:127.0.0.1` and `127.0.0.1` are the same client; key them the same way. */
196
+ function canonicalAddress(address) {
197
+ return address.startsWith('::ffff:') ? address.slice(7) : address;
36
198
  }
37
199
  function jsonRpcError(res, status, code, message) {
38
200
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -49,6 +211,26 @@ export async function startHttpServer(createMcpServer, opts) {
49
211
  throw new OperatorError('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
50
212
  'Without authentication, any network client can execute SSH commands on your hosts.');
51
213
  }
214
+ const authFailureLimit = opts.authFailureLimit ?? DEFAULT_AUTH_FAILURE_LIMIT;
215
+ const authFailureLimiter = authFailureLimit > 0
216
+ ? new AuthFailureLimiter(authFailureLimit)
217
+ : null;
218
+ // Said once, when it turns out to matter. Behind a proxy with `--trustProxy` off, every
219
+ // client is keyed on the proxy's socket address and so shares one budget — which means
220
+ // ten failures from anyone locks out everyone. The README tells operators to terminate
221
+ // TLS at a proxy, so this is the configuration it recommends, and the collapse is
222
+ // invisible until a legitimate client is refused.
223
+ let warnedSharedBudget = false;
224
+ const warnSharedBudget = () => {
225
+ if (warnedSharedBudget)
226
+ return;
227
+ warnedSharedBudget = true;
228
+ console.error('POLICY WARNING: X-Forwarded-For is present but not being used to tell clients ' +
229
+ 'apart, so every client shares one failed-auth budget and one failing client can ' +
230
+ 'lock out the rest. Either --trustProxy is off, or the peer is not a trusted proxy ' +
231
+ '(bare --trustProxy trusts a loopback peer; name others with --trustedProxies), or ' +
232
+ 'the rightmost entry is not an address this server can read.');
233
+ };
52
234
  const rateLimiter = opts.rateLimit && opts.rateLimit > 0
53
235
  ? new RateLimiter(opts.rateLimit)
54
236
  : null;
@@ -102,6 +284,41 @@ export async function startHttpServer(createMcpServer, opts) {
102
284
  // is configured — see the handler below for why that second bit is not a secret.
103
285
  const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
104
286
  if (!isHealthProbe) {
287
+ // Checked before the token is compared, not after — so an exhausted budget answers
288
+ // 429 without evaluating the guess. Gating only the 401 path instead would throttle
289
+ // nothing: the comparison would still happen and a correct token would still be
290
+ // served, so the status code would still tell an attacker which guess was right.
291
+ let key = '';
292
+ if (authFailureLimiter) {
293
+ const resolved = clientKey(req, opts.trustProxy === true, opts.trustedProxies);
294
+ key = resolved.key;
295
+ // Warned in both directions. Without `--trustProxy` a proxied deployment shares
296
+ // one budget; *with* it, an entry that could not be read leaves the same collapse
297
+ // in place, and that case used to be the silent one — the operator had set the
298
+ // flag and had no way to know it was not taking effect.
299
+ if (resolved.forwardedIgnored || (opts.trustProxy !== true && req.headers['x-forwarded-for'])) {
300
+ warnSharedBudget();
301
+ }
302
+ }
303
+ if (authFailureLimiter) {
304
+ const { allowed, retryAfterMs } = authFailureLimiter.peek(key);
305
+ if (!allowed) {
306
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
307
+ res.writeHead(429, {
308
+ 'Content-Type': 'application/json',
309
+ 'Retry-After': String(retryAfterSec),
310
+ });
311
+ res.end(JSON.stringify({
312
+ jsonrpc: '2.0',
313
+ error: {
314
+ code: -32604,
315
+ message: `Too many failed authentication attempts. Retry after ${retryAfterSec}s.`,
316
+ },
317
+ id: null,
318
+ }));
319
+ return;
320
+ }
321
+ }
105
322
  const auth = req.headers.authorization || '';
106
323
  const expected = `Bearer ${bearerToken}`;
107
324
  const authBuf = Buffer.from(auth);
@@ -109,6 +326,7 @@ export async function startHttpServer(createMcpServer, opts) {
109
326
  const match = authBuf.length === expectedBuf.length &&
110
327
  timingSafeEqual(authBuf, expectedBuf);
111
328
  if (!match) {
329
+ authFailureLimiter?.recordFailure(key);
112
330
  // RFC 7235: a 401 must say how to authenticate. MCP clients also parse
113
331
  // JSON-RPC envelopes on this route, so 401 speaks the same dialect as
114
332
  // the 429 and 413 responses rather than a bare {error}.
@@ -248,6 +466,9 @@ export async function startHttpServer(createMcpServer, opts) {
248
466
  if (rateLimiter) {
249
467
  console.error(`Rate limit: ${opts.rateLimit} req/min`);
250
468
  }
469
+ if (authFailureLimiter) {
470
+ console.error(`Auth failure limit: ${authFailureLimit}/min per client`);
471
+ }
251
472
  resolve();
252
473
  });
253
474
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.6.0",
4
+ "version": "2.8.0",
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",
@@ -38,8 +38,8 @@
38
38
  "zod": "^4.4.3"
39
39
  },
40
40
  "devDependencies": {
41
- "@changesets/changelog-github": "^0.7.0",
42
- "@changesets/cli": "^2.31.0",
41
+ "@changesets/changelog-github": "^1.0.0",
42
+ "@changesets/cli": "^3.0.1",
43
43
  "@types/node": "^26.2.0",
44
44
  "@types/ssh2": "^1.15.5",
45
45
  "@vitest/coverage-v8": "^4.1.10",