ssh-mcp 2.5.1 → 2.7.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
@@ -152,8 +152,8 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
152
152
  | `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
153
153
  | `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
154
154
  | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
155
- | `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
156
- | `privileged-command` | Execute with sudo (always requires approval) | — | ✅ |
155
+ | `run-command` | Execute arbitrary commands (destructive/privileged need approval, unless `approvalPolicy = "auto"`) | — | — |
156
+ | `privileged-command` | Execute with sudo (needs approval, unless `approvalPolicy = "auto"`) | — | ✅ |
157
157
  | `sftp-upload` | Upload a file via SFTP | — | ✅ |
158
158
  | `sftp-download` | Download a file via SFTP | ✅ | — |
159
159
  | `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
@@ -422,7 +422,13 @@ denylist; an invalid pattern fails at startup rather than degrading silently.
422
422
  ### Approval Modes
423
423
 
424
424
  - `auto` — no prompts (dev only!)
425
- - `ask-destructive` — prompt for destructive/privileged (default)
425
+ - `ask-destructive` — prompt for destructive/privileged (default). Narrower than it sounds:
426
+ outside the never-allowed list, `destructive` is one `rm -rf /path` pattern, `find` with a
427
+ write/exec flag, an unresolvable command word, a program handed to an interpreter this
428
+ server cannot read (`python3 -c`, `perl -e`, `node -e`, a program arriving on a pipe —
429
+ but not `awk`, whose program is not read), and `sftp-upload`/interactive `open-session` — elevation classifies `privileged`, which also
430
+ prompts. Ordinary writes, service control and signals do not. See
431
+ [SECURITY.md](./SECURITY.md) before relying on this in production.
426
432
  - `ask-all` — prompt for every command
427
433
  - `deny` — reject destructive/privileged commands outright (no prompt)
428
434
 
@@ -481,7 +487,17 @@ When `--opaUrl` is set, commands the built-in engine allows are additionally
481
487
  evaluated by OPA. **OPA can only narrow.** A command the built-in engine has
482
488
  already denied returns that denial without OPA being consulted at all, so a
483
489
  sidecar answering `allow` cannot grant a class the role bindings withhold. To
484
- 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:
485
501
 
486
502
  ```json
487
503
  {
@@ -494,7 +510,7 @@ widen, edit `[policy]`. The request shape follows the AuthZEN Access Evaluation
494
510
  }
495
511
  ```
496
512
 
497
- 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.
498
514
 
499
515
  Example Rego policy (`ssh-mcp.rego`):
500
516
  ```rego
@@ -566,7 +582,7 @@ level number.
566
582
  ### Safe Defaults
567
583
 
568
584
  - **Non-root** user in all examples
569
- - **TOFU** host key verification (accept on first connect, verify after)
585
+ - **TOFU** host key verification (accept on first connect, verify after — within one process; see [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart))
570
586
  - **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
571
587
  - **exec()-only** (no persistent su shells — fixes PTY leak)
572
588
  - **Sudo via stdin** (not argv — fixes process list leak)
@@ -612,6 +628,9 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
612
628
  | `--httpPort` | 3000 | HTTP listen port |
613
629
  | `--httpHost` | 127.0.0.1 | Bind address |
614
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 |
615
634
 
616
635
  Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
617
636
 
@@ -623,6 +642,25 @@ bearer token.
623
642
 
624
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.
625
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
+
626
664
  **Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
627
665
 
628
666
  ---
@@ -672,12 +710,18 @@ Secrets are **never** passed as CLI arguments.
672
710
  | `--httpHost` | 127.0.0.1 | HTTP bind address |
673
711
  | `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
674
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 |
675
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 |
676
718
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
677
719
  | `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
678
720
  | `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
679
721
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
680
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 |
681
725
  | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
682
726
  | `--approvalGrantTtl` | 0 (off) | Auto-approve an identical command for this many ms after approval |
683
727
  | `--auditEntropyScan` | false | Enable entropy-based secret scanning in audit |
@@ -698,8 +742,8 @@ replacement, rather than failing later as a confusing auth error.
698
742
  | v1 | v2 | Notes |
699
743
  |----|----|-------|
700
744
  | `exec` | `read-command` | Allowlisted read-only commands. Prefer this for reads. |
701
- | `exec` | `run-command` | Arbitrary commands. Destructive ones go through the approval gate. |
702
- | `sudo-exec` | `privileged-command` | Always requires approval. Password is piped via stdin. |
745
+ | `exec` | `run-command` | Arbitrary commands. Destructive and privileged ones go through the approval gate, unless `approvalPolicy = "auto"`. |
746
+ | `sudo-exec` | `privileged-command` | Requires approval unless `approvalPolicy = "auto"`. Password is piped via stdin. |
703
747
  | `description` parameter | — | Removed. It was an injection vector (#44) and never reached the host. |
704
748
 
705
749
  **Command results now carry status.** In v1 a failed command rejected with
@@ -739,8 +783,12 @@ and pass `--config <path>`; profiles carry per-host roles and approval policy.
739
783
  ### Host key verification
740
784
 
741
785
  v1 did not verify host keys. v2 defaults to trust-on-first-use and records the
742
- key; a later mismatch fails the connection. Pin explicitly with `trustedHostKey`
743
- in a profile, or pass `--insecureHostKey` to opt out (test environments only).
786
+ key **in memory, for the life of the process**; a later mismatch in that same
787
+ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts`
788
+ is not consulted, so a restart accepts afresh — see
789
+ [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
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).
744
792
 
745
793
  ---
746
794
 
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
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
  });