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 CHANGED
@@ -3,6 +3,7 @@
3
3
  [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
5
  [![CI](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/tufantunc/ssh-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
6
7
  [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/graph/badge.svg?branch=main)](https://codecov.io/gh/tufantunc/ssh-mcp)
7
8
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
8
9
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](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
+ [![SSH MCP Server on Glama](https://glama.ai/mcp/servers/tufantunc/ssh-mcp/badge)](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 OperatorError('No config file found and missing required --host/--user.\n' +
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,
@@ -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
- const rawEval = policy.evaluate(cleanCmd, registry.getProfile(profileName), 'privileged-command');
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
  }
@@ -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
- user: registry.getProfile(profileName).user,
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
- // Sanitization runs inside the try so a rejected (empty, control-char-only,
143
- // over-length) command still leaves an audit trail — a client probing with
144
- // malformed payloads used to leave none.
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
- const profileName = defaultProfileName(profile);
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
- const conn = await resolveConn(profile);
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: '',
@@ -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 nothing beyond "process is up".
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.3.4",
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",