ssh-mcp 2.3.4 → 2.4.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 +62 -0
- package/build/cli.js +56 -18
- package/build/config/loader.js +9 -1
- package/build/errors.js +23 -0
- package/build/policy/classifier.js +175 -2
- package/build/ssh/connection-registry.js +20 -1
- package/build/tools/command-tools.js +5 -2
- package/build/tools/pipeline.js +21 -6
- package/build/tools/session-tools.js +26 -10
- package/build/transport/http.js +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
5
|
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
|
|
6
7
|
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
7
8
|
[](./LICENSE)
|
|
8
9
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
@@ -23,6 +24,10 @@ npm install -g ssh-mcp
|
|
|
23
24
|
|
|
24
25
|
### 2. Configure
|
|
25
26
|
|
|
27
|
+
Without a config the server still starts, so a client or directory can complete the MCP
|
|
28
|
+
handshake and read `tools/list` — but **every tool call is refused** until you configure it,
|
|
29
|
+
with a message naming the path below. Nothing runs on a host until this step is done.
|
|
30
|
+
|
|
26
31
|
Create the config file at the path for your platform:
|
|
27
32
|
|
|
28
33
|
| Platform | Path |
|
|
@@ -98,6 +103,12 @@ nothing. Neither combination leaves you without an exit, which is the lesson of
|
|
|
98
103
|
A supervisor that treats any non-zero status as a failure needs no change. One
|
|
99
104
|
that matched on `1` to detect a startup problem should match on `2` as well.
|
|
100
105
|
|
|
106
|
+
Starting with nothing configured is **not** an exit-2 condition, as of the release that
|
|
107
|
+
added introspection without a config: the server starts so it can be described, and
|
|
108
|
+
refuses each tool call instead. A supervisor that used a non-zero exit to catch an
|
|
109
|
+
unconfigured deployment should watch for `starting unconfigured` on stderr, or read
|
|
110
|
+
`configured` from `GET /health` when running the HTTP transport.
|
|
111
|
+
|
|
101
112
|
### 3. Set credentials via environment variables
|
|
102
113
|
|
|
103
114
|
```bash
|
|
@@ -513,6 +524,45 @@ deny if {
|
|
|
513
524
|
|
|
514
525
|
See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability reporting policy, and deployment checklist.
|
|
515
526
|
|
|
527
|
+
### Supply chain
|
|
528
|
+
|
|
529
|
+
Releases carry signed attestations, published through [Sigstore](https://www.sigstore.dev/)
|
|
530
|
+
and recorded in its public transparency log. They live in **two different stores**, which is
|
|
531
|
+
what decides how each is verified:
|
|
532
|
+
|
|
533
|
+
| Attestation | Predicate | Stored by | Since |
|
|
534
|
+
|---|---|---|---|
|
|
535
|
+
| Build provenance — [SLSA](https://slsa.dev/) Build **Level 2** | `slsa.dev/provenance/v1` | npm | every release |
|
|
536
|
+
| SBOM — CycloneDX and SPDX | `cyclonedx.org/bom`, `spdx.dev/Document` | GitHub | releases after v2.4.0 |
|
|
537
|
+
|
|
538
|
+
Provenance comes from npm [trusted publishing](https://docs.npmjs.com/trusted-publishers):
|
|
539
|
+
the release workflow authenticates with a short-lived OIDC token and no stored credential,
|
|
540
|
+
so there is no long-lived npm token to leak.
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
npm audit signatures # provenance, against an installed tree
|
|
544
|
+
|
|
545
|
+
npm pack ssh-mcp # the SBOM attestation is bound to the tarball, so fetch it
|
|
546
|
+
gh attestation verify ssh-mcp-*.tgz --repo tufantunc/ssh-mcp --predicate-type https://cyclonedx.org/bom
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Both flags on the last command are load-bearing. `gh attestation verify` defaults to the
|
|
550
|
+
SLSA predicate, so without `--predicate-type` it filters the SBOM out and reports nothing
|
|
551
|
+
found — and the provenance it would look for instead is in npm's store, not the GitHub
|
|
552
|
+
store `--repo` queries. Use `https://spdx.dev/Document` for the SPDX one.
|
|
553
|
+
|
|
554
|
+
Both SBOMs are also attached to each
|
|
555
|
+
[GitHub release](https://github.com/tufantunc/ssh-mcp/releases), for reading rather than
|
|
556
|
+
verifying.
|
|
557
|
+
|
|
558
|
+
**Level 2, not 3.** Provenance is signed by the generic GitHub-hosted runner —
|
|
559
|
+
`builder.id` is `https://github.com/actions/runner/github-hosted` — which the build itself
|
|
560
|
+
can influence; Build L3 requires an isolated builder it cannot. Reaching L3 is not
|
|
561
|
+
currently compatible with trusted publishing: npm turns on its own provenance whenever that
|
|
562
|
+
setting is left at its default, and then ignores any externally generated one. So L3 today
|
|
563
|
+
would mean returning to a long-lived npm token — trading the property described above for a
|
|
564
|
+
level number.
|
|
565
|
+
|
|
516
566
|
### Safe Defaults
|
|
517
567
|
|
|
518
568
|
- **Non-root** user in all examples
|
|
@@ -565,6 +615,12 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
|
|
|
565
615
|
|
|
566
616
|
Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
|
|
567
617
|
|
|
618
|
+
`GET /health` answers `{"healthy": true, "configured": <bool>}`. It stays `200` either way —
|
|
619
|
+
`healthy` is liveness — while `configured` is false when no profile is set, which is the
|
|
620
|
+
case of a config bind mount that silently did not attach: the server binds the port and
|
|
621
|
+
refuses every tool call. `GET /status` carries the profile list itself and stays behind the
|
|
622
|
+
bearer token.
|
|
623
|
+
|
|
568
624
|
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.
|
|
569
625
|
|
|
570
626
|
**Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
|
|
@@ -719,3 +775,9 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). Please follow the [security checklist]
|
|
|
719
775
|
## Support
|
|
720
776
|
|
|
721
777
|
If you find SSH MCP Server helpful, consider starring the repository or [sponsoring](https://github.com/sponsors/tufantunc)!
|
|
778
|
+
|
|
779
|
+
## Listed on
|
|
780
|
+
|
|
781
|
+
[](https://glama.ai/mcp/servers/tufantunc/ssh-mcp)
|
|
782
|
+
|
|
783
|
+
Also on the [official MCP registry](https://registry.modelcontextprotocol.io) as `io.github.tufantunc/ssh-mcp`.
|
package/build/cli.js
CHANGED
|
@@ -14,8 +14,23 @@
|
|
|
14
14
|
* escape the boot gate to be testable.
|
|
15
15
|
*/
|
|
16
16
|
import { loadConfig, getConfigPath } from './config/loader.js';
|
|
17
|
-
import { OperatorError, ConfigNotFoundError } from './errors.js';
|
|
17
|
+
import { OperatorError, ConfigNotFoundError, UnconfiguredError } from './errors.js';
|
|
18
18
|
import { HOST_GROUPS } from './policy/engine.js';
|
|
19
|
+
/**
|
|
20
|
+
* Whether the operator asked for a quick start at all.
|
|
21
|
+
*
|
|
22
|
+
* Presence, not truthiness — the same #91 trap `flagEnabled` was written for. `parseArgv`
|
|
23
|
+
* stores `null` for a flag written without `=` and drops bare words, so all three of
|
|
24
|
+
* `--host example.com --user root` (space instead of `=`, the commonest slip), `--host`
|
|
25
|
+
* alone, and `--host= --user=` (a wrapper interpolating unset env vars) produce falsy
|
|
26
|
+
* values while the flags were plainly given. Testing truthiness sent every one of them
|
|
27
|
+
* down the soft path: a server that starts and looks healthy, with the explanation only
|
|
28
|
+
* on a stderr stream an MCP client typically swallows. Only "asked for nothing" softens;
|
|
29
|
+
* a half-given or empty-valued quick start is a typo and still refuses.
|
|
30
|
+
*/
|
|
31
|
+
function nothingRequested(argv) {
|
|
32
|
+
return !('host' in argv) && !('user' in argv);
|
|
33
|
+
}
|
|
19
34
|
export function parseArgv(args = process.argv.slice(2)) {
|
|
20
35
|
const config = {};
|
|
21
36
|
for (const arg of args) {
|
|
@@ -120,6 +135,26 @@ export function resolveHostGroup(argv) {
|
|
|
120
135
|
// operator would read the refusal as policy rather than as their own slip.
|
|
121
136
|
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
122
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The defaults a quick-start profile inherits, from flags alone.
|
|
140
|
+
*
|
|
141
|
+
* Named because an unconfigured start needs them too — `approvalGrantTtlMs` is read while
|
|
142
|
+
* wiring the tools, long before any profile exists, so the two paths have to agree.
|
|
143
|
+
*/
|
|
144
|
+
export function defaultsFromArgv(argv) {
|
|
145
|
+
return {
|
|
146
|
+
sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
|
|
147
|
+
sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
|
|
148
|
+
sessionBackgroundMaxMs: 3_600_000,
|
|
149
|
+
commandTimeoutMs: parseInt(argv.timeout) || 60_000,
|
|
150
|
+
commandMaxChars: parseMaxChars(argv.maxChars),
|
|
151
|
+
commandMaxOutputBytes: 1_048_576,
|
|
152
|
+
connectionIdleReapMs: 900_000,
|
|
153
|
+
commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
|
|
154
|
+
approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
|
|
155
|
+
approvalMode: 'ask-destructive',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
123
158
|
export async function buildAppConfig(argv) {
|
|
124
159
|
// The way past an ACL that could not be read, rather than an operator with no
|
|
125
160
|
// exit. See assertPrivateOnWindows.
|
|
@@ -176,25 +211,28 @@ export async function buildAppConfig(argv) {
|
|
|
176
211
|
if (!(err instanceof ConfigNotFoundError))
|
|
177
212
|
throw err;
|
|
178
213
|
}
|
|
214
|
+
// Nothing configured, and nothing named on the command line either. Start anyway, with
|
|
215
|
+
// no profiles: an MCP client or directory can then complete the handshake and read
|
|
216
|
+
// `tools/list`, and every tool call is refused by `ConnectionRegistry` with the message
|
|
217
|
+
// below — including `list-connections`, which resolves no profile and so had to be told.
|
|
218
|
+
//
|
|
219
|
+
// Measured before this: `initialize` and `tools/list` against our own image drew no
|
|
220
|
+
// JSON-RPC response at all — the process exited on this check first — so every directory
|
|
221
|
+
// and every "add this server" flow saw a crash. Tool definitions are static metadata and
|
|
222
|
+
// the config decides what they may *reach*, so coupling the two bought no safety.
|
|
223
|
+
//
|
|
224
|
+
// A half-given quick start is not this case: `--host` without `--user` is a typo, and an
|
|
225
|
+
// explicit `--config` that points nowhere already threw above. Only "configured nothing"
|
|
226
|
+
// softens — see `nothingRequested`, which tests for the flags being absent rather than
|
|
227
|
+
// falsy so that a valueless or space-separated one stays a typo.
|
|
228
|
+
if (nothingRequested(argv)) {
|
|
229
|
+
console.error(`Warning: starting unconfigured — every tool call will be refused.\n${new UnconfiguredError(getConfigPath()).message}`);
|
|
230
|
+
return { defaults: defaultsFromArgv(argv), profiles: [] };
|
|
231
|
+
}
|
|
179
232
|
if (!argv.host || !argv.user) {
|
|
180
|
-
throw new
|
|
181
|
-
// Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
|
|
182
|
-
// Windows and macOS it named a path this code never reads.
|
|
183
|
-
`Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
|
|
184
|
-
'For quick start: --host=<host> --user=<user> (credentials via env vars).');
|
|
233
|
+
throw new UnconfiguredError(getConfigPath());
|
|
185
234
|
}
|
|
186
|
-
const defaults =
|
|
187
|
-
sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
|
|
188
|
-
sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
|
|
189
|
-
sessionBackgroundMaxMs: 3_600_000,
|
|
190
|
-
commandTimeoutMs: parseInt(argv.timeout) || 60_000,
|
|
191
|
-
commandMaxChars: parseMaxChars(argv.maxChars),
|
|
192
|
-
commandMaxOutputBytes: 1_048_576,
|
|
193
|
-
connectionIdleReapMs: 900_000,
|
|
194
|
-
commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
|
|
195
|
-
approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
|
|
196
|
-
approvalMode: 'ask-destructive',
|
|
197
|
-
};
|
|
235
|
+
const defaults = defaultsFromArgv(argv);
|
|
198
236
|
const profile = {
|
|
199
237
|
name: 'default',
|
|
200
238
|
host: argv.host,
|
package/build/config/loader.js
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir, platform } from 'os';
|
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
4
|
import { parse as parseTOML } from 'smol-toml';
|
|
5
5
|
import { configSchema } from './schema.js';
|
|
6
|
-
import { OperatorError, ConfigNotFoundError } from '../errors.js';
|
|
6
|
+
import { OperatorError, ConfigNotFoundError, UnconfiguredError } from '../errors.js';
|
|
7
7
|
import { assertPrivateOnWindows } from './windows-acl.js';
|
|
8
8
|
export function getConfigPath(customPath) {
|
|
9
9
|
if (customPath)
|
|
@@ -188,6 +188,14 @@ export function getProfile(config, name) {
|
|
|
188
188
|
throw new OperatorError(`No profile selected and no default configured, but ${config.profiles.length} profiles exist: ${names}. ` +
|
|
189
189
|
`Pass a "profile" argument, or set defaults.defaultProfile in the config.`);
|
|
190
190
|
}
|
|
191
|
+
// Defensive: a lookup helper must not hand back `undefined` typed as `Profile`, which
|
|
192
|
+
// is what `profiles[0]` on an empty list used to do — a TypeError somewhere downstream
|
|
193
|
+
// rather than an explanation here. This is the invariant, not the product rule. The
|
|
194
|
+
// decision that an unconfigured server refuses work lives in `ConnectionRegistry`,
|
|
195
|
+
// which guards this branch AND the named-profile one below; reaching this line means
|
|
196
|
+
// a caller went around the registry.
|
|
197
|
+
if (config.profiles.length === 0)
|
|
198
|
+
throw new UnconfiguredError(getConfigPath());
|
|
191
199
|
return config.profiles[0];
|
|
192
200
|
}
|
|
193
201
|
const profile = config.profiles.find((p) => p.name === targetName);
|
package/build/errors.js
CHANGED
|
@@ -65,6 +65,29 @@ export class ConfigNotFoundError extends OperatorError {
|
|
|
65
65
|
this.name = 'ConfigNotFoundError';
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Nothing is configured: no config file, and no `--host`/`--user` on the command line.
|
|
70
|
+
*
|
|
71
|
+
* A type rather than a bare `OperatorError` for the reason `ConfigNotFoundError` is one —
|
|
72
|
+
* two layers have to agree on this state without agreeing on a string. `cli.ts` prints it
|
|
73
|
+
* as a startup warning and the server keeps running; `ConnectionRegistry` throws it at
|
|
74
|
+
* every tool call. Those are different audiences reached through different channels, and
|
|
75
|
+
* a wording edit in one must not quietly stop matching the other.
|
|
76
|
+
*
|
|
77
|
+
* The path is a constructor argument rather than a `getConfigPath()` call inside the
|
|
78
|
+
* message, so this file stays free of the config layer and the text is testable without
|
|
79
|
+
* stubbing `os.platform()`.
|
|
80
|
+
*/
|
|
81
|
+
export class UnconfiguredError extends OperatorError {
|
|
82
|
+
configPath;
|
|
83
|
+
constructor(configPath) {
|
|
84
|
+
super('No config file found and missing required --host/--user.\n' +
|
|
85
|
+
`Either create a config file at ${configPath} or pass --config <path>.\n` +
|
|
86
|
+
'For quick start: --host=<host> --user=<user> (credentials via env vars).');
|
|
87
|
+
this.configPath = configPath;
|
|
88
|
+
this.name = 'UnconfiguredError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
68
91
|
/** Exit status for an operator error, so a supervisor can tell it from a crash. */
|
|
69
92
|
export const EXIT_OPERATOR_ERROR = 2;
|
|
70
93
|
/** Exit status for anything else. */
|
|
@@ -122,6 +122,134 @@ const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
|
122
122
|
* control inside a security release. `gosu` and `run0` were never caught by
|
|
123
123
|
* either form.
|
|
124
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* Class ordering, so a command that contains another can take the higher of the two.
|
|
127
|
+
*
|
|
128
|
+
* Only used by the nesting scan below. Everything else in this file decides a single
|
|
129
|
+
* class outright.
|
|
130
|
+
*/
|
|
131
|
+
const CLASS_RANK = {
|
|
132
|
+
'read-only': 0,
|
|
133
|
+
safe: 1,
|
|
134
|
+
destructive: 2,
|
|
135
|
+
privileged: 3,
|
|
136
|
+
};
|
|
137
|
+
/** Shells that run their next argument as a command when given `-c`. */
|
|
138
|
+
const SHELL_BINARIES = new Set([
|
|
139
|
+
'sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'busybox',
|
|
140
|
+
]);
|
|
141
|
+
/**
|
|
142
|
+
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
143
|
+
*
|
|
144
|
+
* Reached only by input no operator writes — the tests use two hundred levels. The
|
|
145
|
+
* fallback is `privileged` rather than a lower class because the entire point of
|
|
146
|
+
* this scan is that a command we cannot read must not be treated as one we can.
|
|
147
|
+
*/
|
|
148
|
+
const MAX_NESTING_DEPTH = 8;
|
|
149
|
+
/**
|
|
150
|
+
* Split on whitespace the way a shell would, keeping quoted runs together.
|
|
151
|
+
*
|
|
152
|
+
* `parseSegments` splits on `/\s+/`, which turns `sh -c "sudo id"` into
|
|
153
|
+
* `["sh", "-c", "\"sudo", "id\""]` — the argument is no longer a unit, so nothing
|
|
154
|
+
* downstream can classify it as the command it is.
|
|
155
|
+
*/
|
|
156
|
+
function splitRespectingQuotes(command) {
|
|
157
|
+
const words = [];
|
|
158
|
+
let current = '';
|
|
159
|
+
let quote = null;
|
|
160
|
+
for (const ch of command) {
|
|
161
|
+
if (quote) {
|
|
162
|
+
if (ch === quote)
|
|
163
|
+
quote = null;
|
|
164
|
+
else
|
|
165
|
+
current += ch;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ch === '"' || ch === "'") {
|
|
169
|
+
quote = ch;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (/\s/.test(ch)) {
|
|
173
|
+
if (current)
|
|
174
|
+
words.push(current);
|
|
175
|
+
current = '';
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
current += ch;
|
|
179
|
+
}
|
|
180
|
+
if (current)
|
|
181
|
+
words.push(current);
|
|
182
|
+
return words;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The commands hiding inside a command.
|
|
186
|
+
*
|
|
187
|
+
* The elevation scan reads tokens produced by splitting on `;&|` and whitespace, so
|
|
188
|
+
* it only ever saw the outer command: `echo $(sudo id)` tokenised to
|
|
189
|
+
* `["echo", "$(sudo", "id)"]`, `echo` was taken to be the real command, and no
|
|
190
|
+
* elevation was found (GHSA-v8jh-gv7v-3gvq). The destructive scan never had this
|
|
191
|
+
* problem because it reads the raw text — which is why `echo $(rm -rf /)` was
|
|
192
|
+
* classified correctly the whole time. This closes that asymmetry by pulling the
|
|
193
|
+
* inner commands out so they can be classified in their own right.
|
|
194
|
+
*
|
|
195
|
+
* Four carriers, all of which a remote shell expands and runs:
|
|
196
|
+
* `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
|
|
197
|
+
* given `-c`.
|
|
198
|
+
*/
|
|
199
|
+
export function nestedCommands(command) {
|
|
200
|
+
const found = [];
|
|
201
|
+
// `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
|
|
202
|
+
// cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
|
|
203
|
+
// matters most.
|
|
204
|
+
for (let i = 0; i < command.length; i++) {
|
|
205
|
+
const opensSubstitution = command[i] === '$' && command[i + 1] === '(';
|
|
206
|
+
const opensProcess = (command[i] === '<' || command[i] === '>') && command[i + 1] === '(';
|
|
207
|
+
if (!opensSubstitution && !opensProcess)
|
|
208
|
+
continue;
|
|
209
|
+
// `$((1 + 1))` is arithmetic, not a command. Skipping it keeps the approval
|
|
210
|
+
// prompt off `echo $((1 + 1))`.
|
|
211
|
+
if (opensSubstitution && command[i + 2] === '(')
|
|
212
|
+
continue;
|
|
213
|
+
let depth = 0;
|
|
214
|
+
for (let j = i + 1; j < command.length; j++) {
|
|
215
|
+
if (command[j] === '(')
|
|
216
|
+
depth++;
|
|
217
|
+
else if (command[j] === ')') {
|
|
218
|
+
depth--;
|
|
219
|
+
if (depth === 0) {
|
|
220
|
+
found.push(command.slice(i + 2, j));
|
|
221
|
+
i = j;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Backticks. No nesting to balance — the shell requires escaping to nest them.
|
|
228
|
+
const backticks = command.match(/`([^`]*)`/g);
|
|
229
|
+
if (backticks) {
|
|
230
|
+
for (const raw of backticks)
|
|
231
|
+
found.push(raw.slice(1, -1));
|
|
232
|
+
}
|
|
233
|
+
// `sh -c <command>`. Carries no substitution at all, so the scan above does not
|
|
234
|
+
// see it; found while mapping the reported bypass rather than in the report.
|
|
235
|
+
const words = splitRespectingQuotes(command);
|
|
236
|
+
for (let i = 0; i < words.length; i++) {
|
|
237
|
+
if (!SHELL_BINARIES.has(stripPath(unquote(words[i]))))
|
|
238
|
+
continue;
|
|
239
|
+
for (let j = i + 1; j < words.length; j++) {
|
|
240
|
+
if (words[j] === '-c') {
|
|
241
|
+
if (words[j + 1] !== undefined)
|
|
242
|
+
found.push(words[j + 1]);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
// Only flags may sit between the shell and its `-c`; anything else means
|
|
246
|
+
// this was not a `-c` invocation.
|
|
247
|
+
if (!words[j].startsWith('-'))
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return found.filter((c) => c.trim().length > 0);
|
|
252
|
+
}
|
|
125
253
|
const PRIVILEGE_PREFIXES = new Set([
|
|
126
254
|
'sudo', 'su', 'doas', 'pkexec',
|
|
127
255
|
// BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
|
|
@@ -356,11 +484,25 @@ const FORBIDDEN_RULES = [
|
|
|
356
484
|
* lives in FORBIDDEN_RULES and a caller checking only the regexes would quietly
|
|
357
485
|
* permit `sudo reboot`.
|
|
358
486
|
*/
|
|
359
|
-
export function findForbiddenMatch(command) {
|
|
487
|
+
export function findForbiddenMatch(command, depth = 0) {
|
|
360
488
|
for (const rule of FORBIDDEN_RULES) {
|
|
361
489
|
if (rule.test(command))
|
|
362
490
|
return rule.label;
|
|
363
491
|
}
|
|
492
|
+
// The same carriers the class scan reads, for the same reason. This list is the
|
|
493
|
+
// one unconditional rule in the policy — forbidden regardless of role, tier or
|
|
494
|
+
// approval — and it was decided from the outer command alone, so
|
|
495
|
+
// `sh -c "shutdown -h now"` and `echo $(shutdown -h now)` were not forbidden.
|
|
496
|
+
// They still classified `destructive`, which on the `prod` tier degrades an
|
|
497
|
+
// absolute `deny` into `require-approval`: a rule that answers "never" became
|
|
498
|
+
// one a human can click through.
|
|
499
|
+
if (depth >= MAX_NESTING_DEPTH)
|
|
500
|
+
return null;
|
|
501
|
+
for (const inner of nestedCommands(command)) {
|
|
502
|
+
const match = findForbiddenMatch(inner, depth + 1);
|
|
503
|
+
if (match !== null)
|
|
504
|
+
return match;
|
|
505
|
+
}
|
|
364
506
|
return null;
|
|
365
507
|
}
|
|
366
508
|
export function isForbidden(command) {
|
|
@@ -401,10 +543,41 @@ export function extractBinary(command) {
|
|
|
401
543
|
const parts = cmd.split(/\s+/);
|
|
402
544
|
return parts[0] || '';
|
|
403
545
|
}
|
|
404
|
-
export function classifyCommand(command) {
|
|
546
|
+
export function classifyCommand(command, depth = 0) {
|
|
405
547
|
const trimmed = command.trim();
|
|
406
548
|
const binary = extractBinary(trimmed);
|
|
407
549
|
const fullCommand = trimmed;
|
|
550
|
+
// A command that carries another command decides nothing on its own: the remote
|
|
551
|
+
// shell expands `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside,
|
|
552
|
+
// so the class has to be the higher of the two. Before this, the outer command
|
|
553
|
+
// decided alone and `echo $(sudo id)` was `safe` — a class the default rules grant
|
|
554
|
+
// on `prod` to `operator` and `admin`, while granting `privileged` there to nobody
|
|
555
|
+
// at all (GHSA-v8jh-gv7v-3gvq). It also skipped the approval prompt, since only
|
|
556
|
+
// `destructive` and `privileged` raise one, and the audit record said `safe`.
|
|
557
|
+
//
|
|
558
|
+
// Runs before the checks below rather than after, so the class it produces is not
|
|
559
|
+
// something a later branch can lower.
|
|
560
|
+
if (depth < MAX_NESTING_DEPTH) {
|
|
561
|
+
let highest = null;
|
|
562
|
+
for (const inner of nestedCommands(trimmed)) {
|
|
563
|
+
const parsed = classifyCommand(inner, depth + 1);
|
|
564
|
+
if (highest === null || CLASS_RANK[parsed.class] > CLASS_RANK[highest.class]) {
|
|
565
|
+
highest = parsed;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// `binary` comes from the inner command deliberately: it is what the audit
|
|
569
|
+
// record and the refusal message name, and naming the outer `echo` would
|
|
570
|
+
// describe the wrong process as the one that ran as root.
|
|
571
|
+
if (highest !== null && CLASS_RANK[highest.class] > CLASS_RANK['safe']) {
|
|
572
|
+
return { binary: highest.binary, fullCommand, class: highest.class };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// Nesting this deep is not something an operator writes, and we have stopped
|
|
577
|
+
// reading. Refusing to guess is the only answer consistent with the rest of
|
|
578
|
+
// this function.
|
|
579
|
+
return { binary, fullCommand, class: 'privileged' };
|
|
580
|
+
}
|
|
408
581
|
// `binary` names the subject of the class. For everything below it is the
|
|
409
582
|
// leading command; here it is the one that runs as root, which are the same
|
|
410
583
|
// thing only when the prefix leads.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getProfile as getConfigProfile } from '../config/loader.js';
|
|
1
|
+
import { getProfile as getConfigProfile, getConfigPath } from '../config/loader.js';
|
|
2
|
+
import { UnconfiguredError } from '../errors.js';
|
|
2
3
|
import { resolveCredentials } from '../config/credential-resolver.js';
|
|
3
4
|
import { SSHConnection } from './connection.js';
|
|
4
5
|
export class ConnectionRegistry {
|
|
@@ -69,7 +70,25 @@ export class ConnectionRegistry {
|
|
|
69
70
|
const profile = this.getProfile(profileName);
|
|
70
71
|
return this.connections.get(profile.name);
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Refuse everything while nothing is configured.
|
|
75
|
+
*
|
|
76
|
+
* The server now starts with `profiles: []` so a directory or a client's "add server"
|
|
77
|
+
* flow can read `tools/list` (see cli.ts). This is where that state stops being
|
|
78
|
+
* readable and starts being refused, and it sits here rather than in the loader's
|
|
79
|
+
* `getProfile` for one measured reason: the loader's empty check can only live in the
|
|
80
|
+
* no-profile-named branch, so a client calling `run-command` with `profile: "prod"`
|
|
81
|
+
* against an unconfigured server got `Profile "prod" not found` — a message telling the
|
|
82
|
+
* operator they mistyped a name when in fact they have no config at all, and omitting
|
|
83
|
+
* the config path this whole change exists to deliver. Checking before the lookup
|
|
84
|
+
* covers both.
|
|
85
|
+
*/
|
|
86
|
+
assertConfigured() {
|
|
87
|
+
if (this.config.profiles.length === 0)
|
|
88
|
+
throw new UnconfiguredError(getConfigPath());
|
|
89
|
+
}
|
|
72
90
|
getProfile(profileName) {
|
|
91
|
+
this.assertConfigured();
|
|
73
92
|
return getConfigProfile(this.config, profileName);
|
|
74
93
|
}
|
|
75
94
|
listConnections() {
|
|
@@ -42,7 +42,6 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
42
42
|
command: z.string().describe('Command to execute with sudo'),
|
|
43
43
|
profile: z.string().optional().describe('Profile name'),
|
|
44
44
|
}, { destructiveHint: true }, async ({ command, profile }, extra) => {
|
|
45
|
-
const profileName = defaultProfileName(profile);
|
|
46
45
|
return runAudited(command, {
|
|
47
46
|
toolName: 'privileged-command',
|
|
48
47
|
failureClass: 'privileged',
|
|
@@ -51,7 +50,11 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
51
50
|
// Evaluate the bare command too: the sudo wrapper would otherwise
|
|
52
51
|
// hide a forbidden command inside a quoted `sh -c` argument.
|
|
53
52
|
preCheck: (cleanCmd) => {
|
|
54
|
-
|
|
53
|
+
// Resolved here rather than before `runAudited`: `defaultProfileName` reaches
|
|
54
|
+
// `registry.getProfile()`, which refuses on an unconfigured server, and outside
|
|
55
|
+
// the pipeline's try that refusal escaped without an audit record. `preCheck`
|
|
56
|
+
// runs inside it.
|
|
57
|
+
const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)), 'privileged-command');
|
|
55
58
|
if (rawEval.decision === 'deny') {
|
|
56
59
|
throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
|
|
57
60
|
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -75,7 +75,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
75
75
|
await audit.record({
|
|
76
76
|
mcpRequestId: ctx.requestId,
|
|
77
77
|
profile: profileName,
|
|
78
|
-
|
|
78
|
+
// Looked up without throwing. This used to be `registry.getProfile(profileName).user`,
|
|
79
|
+
// which throws for exactly the reason the tool call is being audited as a failure —
|
|
80
|
+
// an unconfigured server, or a profile name that does not exist — and `auditFailure`
|
|
81
|
+
// swallows that by design. The record the operator most needs was the one record that
|
|
82
|
+
// could not be written.
|
|
83
|
+
user: profileUser(profileName),
|
|
79
84
|
command,
|
|
80
85
|
commandClass: evaluation.commandClass,
|
|
81
86
|
binary: evaluation.binary,
|
|
@@ -113,6 +118,10 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
113
118
|
function defaultProfileName(profile) {
|
|
114
119
|
return profile || registry.getProfile().name;
|
|
115
120
|
}
|
|
121
|
+
/** The profile's SSH user, or a placeholder — never throws, so an audit write cannot fail. */
|
|
122
|
+
function profileUser(profileName) {
|
|
123
|
+
return registry.listAllProfiles().find((p) => p.name === profileName)?.user ?? '(unresolved)';
|
|
124
|
+
}
|
|
116
125
|
function makeProgressSender(extra) {
|
|
117
126
|
const token = extra?._meta?.progressToken;
|
|
118
127
|
if (token === undefined)
|
|
@@ -135,15 +144,21 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
135
144
|
*/
|
|
136
145
|
async function runAudited(command, opts, run) {
|
|
137
146
|
const ctx = makeCtx(opts.extra, opts.profile, opts.session);
|
|
138
|
-
const profileName = defaultProfileName(opts.profile);
|
|
139
|
-
const profile = registry.getProfile(profileName);
|
|
140
147
|
const onProgress = makeProgressSender(opts.extra);
|
|
141
148
|
const abortSignal = opts.extra?.signal;
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
149
|
+
// Named before the try because `auditFailure` needs something to file the record
|
|
150
|
+
// under even when resolution itself is what failed. Reassigned to the resolved name as
|
|
151
|
+
// the first statement inside, so the quota key and the audit record are unchanged on
|
|
152
|
+
// every path that gets that far.
|
|
153
|
+
let profileName = opts.profile ?? '(default)';
|
|
154
|
+
// Profile resolution and sanitization both run inside the try, so a rejected call
|
|
155
|
+
// still leaves an audit trail — a client probing with malformed payloads, or probing
|
|
156
|
+
// a server that has no config at all, used to leave none. The unconfigured refusal
|
|
157
|
+
// reaches this the same way a bad command does.
|
|
145
158
|
const state = { command };
|
|
146
159
|
try {
|
|
160
|
+
profileName = defaultProfileName(opts.profile);
|
|
161
|
+
const profile = registry.getProfile(profileName);
|
|
147
162
|
let effective = opts.synthetic ? command : sanitizeCommand(command, profile.maxChars);
|
|
148
163
|
state.command = effective;
|
|
149
164
|
opts.preCheck?.(effective);
|
|
@@ -38,6 +38,14 @@ export function registerSessionTools({ server, registry }, pipeline) {
|
|
|
38
38
|
const { runAudited, resolveConn, auditResult, auditFailure, makeCtx, defaultProfileName } = pipeline;
|
|
39
39
|
// ─── list-connections ──────────────────────────────────────────────────
|
|
40
40
|
server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
|
|
41
|
+
// The one tool that reads only `listAllProfiles()` and so never touches
|
|
42
|
+
// `getProfile`. Without this it answered an unconfigured server with a
|
|
43
|
+
// zero-length success — on the tool whose own description sends an agent here
|
|
44
|
+
// first ("discover available hosts before running commands"), and on the one
|
|
45
|
+
// channel where the startup stderr warning is not visible. The agent saw "no
|
|
46
|
+
// hosts" rather than "not configured". Refusing here is what makes the claim in
|
|
47
|
+
// the README, the changeset and the startup warning true of all eleven tools.
|
|
48
|
+
registry.assertConfigured();
|
|
41
49
|
const connections = registry.listConnections();
|
|
42
50
|
const profiles = registry.listAllProfiles();
|
|
43
51
|
const lines = profiles.map((p) => {
|
|
@@ -104,19 +112,27 @@ export function registerSessionTools({ server, registry }, pipeline) {
|
|
|
104
112
|
profile: z.string().optional().describe('Profile name'),
|
|
105
113
|
}, { destructiveHint: true }, async ({ name, profile }, extra) => {
|
|
106
114
|
const cleanName = sanitizeSessionName(name);
|
|
107
|
-
|
|
115
|
+
// Both the profile name and the connection are resolved inside the try below. They
|
|
116
|
+
// used to be resolved above it, so a refusal — an unconfigured server, an unknown
|
|
117
|
+
// profile name — escaped before `auditFailure` could record that a release was
|
|
118
|
+
// attempted. The placeholders are what the record is filed under if resolution is
|
|
119
|
+
// itself what failed.
|
|
120
|
+
let profileName = profile ?? '(default)';
|
|
108
121
|
const ctx = makeCtx(extra, profileName, cleanName);
|
|
109
|
-
|
|
110
|
-
// Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
|
|
111
|
-
// distinguishable from ending a local shell — `open-session` encodes its type the
|
|
112
|
-
// same way.
|
|
113
|
-
// Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
|
|
114
|
-
// tool handler reaching for a constructor identity is the layer leak this repo's own
|
|
115
|
-
// rules call out.
|
|
116
|
-
const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
|
|
117
|
-
const command = `session:close ${kind} ${cleanName}`;
|
|
122
|
+
let command = `session:close unknown ${cleanName}`;
|
|
118
123
|
const startedAt = Date.now();
|
|
119
124
|
try {
|
|
125
|
+
profileName = defaultProfileName(profile);
|
|
126
|
+
ctx.profile = profileName;
|
|
127
|
+
const conn = await resolveConn(profile);
|
|
128
|
+
// Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
|
|
129
|
+
// distinguishable from ending a local shell — `open-session` encodes its type the
|
|
130
|
+
// same way.
|
|
131
|
+
// Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
|
|
132
|
+
// tool handler reaching for a constructor identity is the layer leak this repo's own
|
|
133
|
+
// rules call out.
|
|
134
|
+
const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
|
|
135
|
+
command = `session:close ${kind} ${cleanName}`;
|
|
120
136
|
const outcome = await conn.closeSession(cleanName);
|
|
121
137
|
await auditResult(ctx, profileName, command, RELEASE, {
|
|
122
138
|
stdout: '',
|
package/build/transport/http.js
CHANGED
|
@@ -98,7 +98,8 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
98
98
|
const httpServer = createServer(async (req, res) => {
|
|
99
99
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
100
100
|
// Liveness probes are conventionally unauthenticated, and the README lists
|
|
101
|
-
// /health without an auth caveat. It exposes
|
|
101
|
+
// /health without an auth caveat. It exposes "process is up" and whether any profile
|
|
102
|
+
// is configured — see the handler below for why that second bit is not a secret.
|
|
102
103
|
const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
|
|
103
104
|
if (!isHealthProbe) {
|
|
104
105
|
const auth = req.headers.authorization || '';
|
|
@@ -210,8 +211,18 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
212
213
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
214
|
+
// `configured` is liveness telling the truth about readiness. Since the server
|
|
215
|
+
// learned to start with nothing configured, an HTTP deployment whose config bind
|
|
216
|
+
// mount silently did not attach comes up, binds the port, and fails 100% of tool
|
|
217
|
+
// calls — while this probe said `healthy: true` and the explaining stderr warning
|
|
218
|
+
// scrolled past at boot. The status stays 200 so an existing probe does not start
|
|
219
|
+
// failing on upgrade; the field is what an operator can alert on.
|
|
220
|
+
//
|
|
221
|
+
// It widens what this unauthenticated route discloses by exactly one bit, and that
|
|
222
|
+
// bit is worth little to anyone: a server with no profile is one that cannot reach
|
|
223
|
+
// any host. `/status` carries the profile list itself and stays behind the token.
|
|
213
224
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
214
|
-
res.end(JSON.stringify({ healthy: true }));
|
|
225
|
+
res.end(JSON.stringify({ healthy: true, configured: registry.listAllProfiles().length > 0 }));
|
|
215
226
|
return;
|
|
216
227
|
}
|
|
217
228
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.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",
|