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 +58 -10
- package/build/cli.js +76 -0
- package/build/index.js +13 -4
- package/build/policy/classifier.js +503 -81
- package/build/policy/engine.js +74 -7
- package/build/tools/descriptions.js +2 -2
- package/build/transport/http.js +240 -19
- package/package.json +3 -3
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
|
|
156
|
-
| `privileged-command` | Execute with sudo (
|
|
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]`.
|
|
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` |
|
|
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
|
|
743
|
-
|
|
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
|
|
32
|
-
|
|
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
|
});
|