ssh-mcp 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,13 @@ npm install -g ssh-mcp
23
23
 
24
24
  ### 2. Configure
25
25
 
26
- Create `~/.config/ssh-mcp/config.toml` (Linux/macOS) or `%APPDATA%\ssh-mcp\config.toml` (Windows):
26
+ Create the config file at the path for your platform:
27
+
28
+ | Platform | Path |
29
+ |---|---|
30
+ | Linux | `~/.config/ssh-mcp/config.toml` (or `$XDG_CONFIG_HOME/ssh-mcp/config.toml`) |
31
+ | macOS | `~/Library/Application Support/ssh-mcp/config.toml` |
32
+ | Windows | `%APPDATA%\ssh-mcp\config.toml` |
27
33
 
28
34
  ```toml
29
35
  [defaults]
@@ -42,9 +48,39 @@ approvalPolicy = "auto" # dev is permissive
42
48
  ```
43
49
 
44
50
  ```bash
45
- chmod 600 ~/.config/ssh-mcp/config.toml
51
+ chmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.toml
46
52
  ```
47
53
 
54
+ The server refuses to start if anyone but you can read the config, since it
55
+ decides which hosts, roles and policy rules this server honours.
56
+
57
+ On Linux and macOS that is the mode check above — the directory too, which is why
58
+ `chmod 700` is in that command: `mkdir -p` under the default umask leaves it 0755. On Windows there are no mode
59
+ bits, so it reads the ACL instead and requires that only your account, `SYSTEM`
60
+ and `Administrators` hold access. A config under `%APPDATA%` inherits exactly
61
+ that and needs nothing done to it. One created elsewhere does not — a file under
62
+ `C:\` inherits read access for every local account and modify for every
63
+ authenticated one — and the refusal names the two `icacls` commands that fix it,
64
+ or, when the directory is a filesystem root or sits outside your user profile, leads with
65
+ moving the config instead — it still prints the commands, but removing entries from a
66
+ directory other accounts share is a judgement only you can make.
67
+
68
+ If the ACL cannot be read at all, the server refuses rather than assuming the file is
69
+ private, and `--allowUncheckedConfigAcl` loads it unverified. Two cases do not need the
70
+ flag — `icacls` being absent from the machine, and the check running out of time — because
71
+ both are statements about the machine rather than about the file. Each warns and loads.
72
+
73
+ ### Exit statuses
74
+
75
+ | Status | Meaning |
76
+ |---|---|
77
+ | `0` | Clean shutdown |
78
+ | `1` | A defect in the server — printed with a stack trace; please report it |
79
+ | `2` | How it was invoked or configured — printed as a message, no stack |
80
+
81
+ A supervisor that treats any non-zero status as a failure needs no change. One
82
+ that matched on `1` to detect a startup problem should match on `2` as well.
83
+
48
84
  ### 3. Set credentials via environment variables
49
85
 
50
86
  ```bash
@@ -479,7 +515,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability report
479
515
  - [ ] Restrict network egress on target hosts
480
516
  - [ ] Use `readOnly = true` for monitoring profiles
481
517
  - [ ] Review audit logs regularly
482
- - [ ] Run `chmod 600 config.toml`
518
+ - [ ] Run `chmod 700 <config dir> && chmod 600 config.toml` (Windows: the ACL under `%APPDATA%` is already restricted)
483
519
 
484
520
  ---
485
521
 
@@ -547,7 +583,7 @@ Secrets are **never** passed as CLI arguments.
547
583
 
548
584
  | Flag | Default | Description |
549
585
  |------|---------|-------------|
550
- | `--config` | XDG path | Path to TOML config file |
586
+ | `--config` | platform config dir (see [Configure](#2-configure)) | Path to TOML config file |
551
587
  | `--host` | — | Quick start: SSH host (creates single-profile config) |
552
588
  | `--user` | — | Quick start: SSH username |
553
589
  | `--port` | 22 | Quick start: SSH port |
@@ -565,6 +601,7 @@ Secrets are **never** passed as CLI arguments.
565
601
  | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
566
602
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
567
603
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
604
+ | `--allowUncheckedConfigAcl` | false | Windows: load the config even if its ACL could not be read |
568
605
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
569
606
  | `--opaUrl` | — | OPA sidecar URL for external policy |
570
607
  | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { resolve } from 'path';
3
+ import { OperatorError } from '../errors.js';
3
4
  let keyringGet = null;
4
5
  let keyringSet = null;
5
6
  export function isKeychainAvailable() {
@@ -25,7 +26,7 @@ export async function initKeychain() {
25
26
  }
26
27
  export async function setKeychainEntry(service, account, password) {
27
28
  if (!keyringSet)
28
- throw new Error('Keychain not initialized. Install @napi-rs/keyring.');
29
+ throw new OperatorError('Keychain not initialized. Install @napi-rs/keyring.');
29
30
  await keyringSet(service, account, password);
30
31
  }
31
32
  export async function resolveCredentials(profile) {
@@ -110,7 +111,7 @@ export async function resolveCredentials(profile) {
110
111
  keychain: `the OS keychain returned nothing${profile.keychainEntry ? ` for "${profile.keychainEntry}"` : ' — keychainEntry is not set on the profile'}`,
111
112
  password: `SSH_MCP_PASSWORD and SSH_MCP_${profile.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PASSWORD are both unset`,
112
113
  };
113
- throw new Error(`No credentials resolved for profile "${profile.name}", which asks for auth = "${asked}": ${why[asked] ?? 'nothing was found'}.\n` +
114
+ throw new OperatorError(`No credentials resolved for profile "${profile.name}", which asks for auth = "${asked}": ${why[asked] ?? 'nothing was found'}.\n` +
114
115
  'Fix that method rather than switching to another if you can — the fallbacks are ordered by how much ' +
115
116
  'they expose. In order: an SSH agent (nothing on disk), an encrypted key with SSH_MCP_KEY, the OS ' +
116
117
  `keychain, and last a password in SSH_MCP_PASSWORD, which is readable by every process running as you.`);
@@ -3,6 +3,8 @@ 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';
7
+ import { assertPrivateOnWindows } from './windows-acl.js';
6
8
  export function getConfigPath(customPath) {
7
9
  if (customPath)
8
10
  return customPath;
@@ -17,43 +19,93 @@ export function getConfigPath(customPath) {
17
19
  const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, '.config');
18
20
  return join(xdgConfig, 'ssh-mcp', 'config.toml');
19
21
  }
20
- export async function checkPermissions(filePath) {
21
- try {
22
- const fileStat = await stat(filePath);
23
- const mode = fileStat.mode & 0o777;
24
- if (mode & 0o077) {
25
- throw new Error(`Config file ${filePath} is group/world accessible (mode ${mode.toString(8)}). Required: 0600. Run: chmod 600 ${filePath}`);
22
+ /**
23
+ * Refuse a config file that anyone but its owner can read, because it holds the
24
+ * hosts, roles and policy rules that decide what this server will run.
25
+ *
26
+ * One question, asked of two different access-control systems. Mode bits are
27
+ * POSIX's answer and are meaningless on Windows, which is the whole of #138:
28
+ * Node synthesises `0o666` for every readable file there, so `& 0o077` was
29
+ * non-zero unconditionally and every Windows config that has ever existed was
30
+ * rejected. The `chmod 600` it prescribed could not lift that rejection —
31
+ * measured on Windows 11, `chmod(path, 0o600)` leaves the mode at `0o666`,
32
+ * because `fs.chmod` there only toggles the read-only bit. Following the
33
+ * instruction exactly returned the operator to where they started.
34
+ *
35
+ * Windows reads the ACL instead; see assertPrivateOnWindows.
36
+ */
37
+ export async function checkPermissions(filePath, opts = {}) {
38
+ return platform() === 'win32'
39
+ ? assertPrivateOnWindows(filePath, opts)
40
+ : checkPosixMode(filePath);
41
+ }
42
+ /**
43
+ * Split out so its own refusals do not pass back through the `catch` that
44
+ * tolerates ENOENT. They survived it only because an OperatorError carries no
45
+ * `.code`, which was a coincidence rather than a design.
46
+ */
47
+ async function checkPosixMode(filePath) {
48
+ const dir = dirname(filePath);
49
+ const modeOf = async (path) => {
50
+ try {
51
+ return (await stat(path)).mode & 0o777;
26
52
  }
27
- const dirMode = (await stat(dirname(filePath))).mode & 0o777;
28
- if (dirMode & 0o077) {
29
- throw new Error(`Config directory is group/world accessible (mode ${dirMode.toString(8)}). Required: 0700.`);
53
+ catch (err) {
54
+ if (err.code === 'ENOENT')
55
+ return null;
56
+ throw err;
30
57
  }
58
+ };
59
+ // The file is tested before the directory is even stat'd, which is the order
60
+ // main used: batching both stats first meant a 0644 file whose directory
61
+ // vanished in between took the ENOENT path and was accepted.
62
+ const fileMode = await modeOf(filePath);
63
+ if (fileMode === null)
64
+ return;
65
+ if (fileMode & 0o077) {
66
+ throw new OperatorError(`Config file ${filePath} is group/world accessible (mode ${fileMode.toString(8)}). ` +
67
+ `Required: 0600. Run: chmod 600 ${filePath}`);
31
68
  }
32
- catch (err) {
33
- if (err.code === 'ENOENT')
34
- return;
35
- throw err;
69
+ const dirMode = await modeOf(dir);
70
+ if (dirMode !== null && dirMode & 0o077) {
71
+ throw new OperatorError(`Config directory ${dir} is group/world accessible (mode ${dirMode.toString(8)}). ` +
72
+ `Required: 0700. Run: chmod 700 ${dir}\n` +
73
+ // The published Docker image created this directory 0755, so a container
74
+ // with a config bind-mounted at the default path met this on every start
75
+ // with no way to chmod a path baked into the image.
76
+ 'If the directory is not yours to change — a container image, or a mount point — ' +
77
+ 'bind-mount a 0700 directory over it, or point --config at one you control.');
36
78
  }
37
79
  }
38
- export async function loadConfig(customPath) {
80
+ export async function loadConfig(customPath, opts = {}) {
39
81
  const configPath = getConfigPath(customPath);
40
82
  let raw;
41
83
  try {
42
84
  raw = await readFile(configPath, 'utf8');
43
85
  }
44
86
  catch (err) {
45
- if (err.code === 'ENOENT' && !customPath) {
46
- throw new Error(`No config file found at ${configPath}. Create one or use --config <path>. See documentation for the TOML schema.`);
47
- }
48
- throw err;
87
+ // Also for an explicit --config that points nowhere. That used to escape
88
+ // as the raw system error ("ENOENT: no such file or directory, open ..."),
89
+ // which says the same thing in the vocabulary of a syscall.
90
+ if (err.code === 'ENOENT')
91
+ throw new ConfigNotFoundError(configPath);
92
+ // Everything else here is still the operator's file — a directory passed as
93
+ // --config, a mode or ACL that denies reading, a mount that went away. Those
94
+ // escaped raw too, so `reportFatal` printed them with a stack through our
95
+ // own frames: the presentation this change exists to remove.
96
+ // `.code` is carried through for the same reason ConfigNotFoundError keeps it: a
97
+ // wrapper script distinguishing "unreadable, remount and retry" from "absent, run the
98
+ // installer" was matching on it, and preserving it on one branch while dropping it on
99
+ // the adjacent one is a half-applied rule.
100
+ throw Object.assign(new OperatorError(`Config file ${configPath} could not be read: ${err.message}`), { code: err.code, cause: err });
49
101
  }
50
- await checkPermissions(configPath);
102
+ await checkPermissions(configPath, opts);
51
103
  let parsed;
52
104
  try {
53
105
  parsed = parseTOML(raw);
54
106
  }
55
107
  catch (err) {
56
- throw new Error(`Failed to parse TOML config at ${configPath}: ${err.message}`);
108
+ throw new OperatorError(`Failed to parse TOML config at ${configPath}: ${err.message}`);
57
109
  }
58
110
  const result = configSchema.safeParse(parsed);
59
111
  if (!result.success) {
@@ -63,7 +115,7 @@ export async function loadConfig(customPath) {
63
115
  const issues = result.error.issues
64
116
  .map((i) => ` ${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`)
65
117
  .join('\n');
66
- throw new Error(`Config validation error:\n${issues}`);
118
+ throw new OperatorError(`Config validation error:\n${issues}`);
67
119
  }
68
120
  return normalizeConfig(result.data);
69
121
  }
@@ -95,9 +147,10 @@ function normalizeConfig(raw) {
95
147
  // survives anywhere in AppConfig. Leaving the raw value on `defaults` left one
96
148
  // object carrying two encodings of "unlimited" — `profile.maxChars` as
97
149
  // MAX_SAFE_INTEGER, `defaults.commandMaxChars` as 0 — with nothing in the
98
- // types to tell them apart. index.ts:161 already copies straight across
99
- // (`maxChars: defaults.commandMaxChars`), and was correct only because that
100
- // path's value comes from parseMaxChars, which never returns 0. Correct by
150
+ // types to tell them apart. buildAppConfig's quick-start path already copies it
151
+ // straight across (`maxChars: defaults.commandMaxChars`), and was correct only
152
+ // because that path's value comes from parseMaxChars, which never returns 0.
153
+ // Correct by
101
154
  // coincidence of another surface is not a property to leave standing when the
102
155
  // wrong encoding rejects every non-empty command.
103
156
  const defaults = {
@@ -130,13 +183,13 @@ export function getProfile(config, name) {
130
183
  // unambiguous; more than one is a question the caller has to answer.
131
184
  if (config.profiles.length > 1) {
132
185
  const names = config.profiles.map((p) => p.name).join(', ');
133
- throw new Error(`No profile selected and no default configured, but ${config.profiles.length} profiles exist: ${names}. ` +
186
+ throw new OperatorError(`No profile selected and no default configured, but ${config.profiles.length} profiles exist: ${names}. ` +
134
187
  `Pass a "profile" argument, or set defaults.defaultProfile in the config.`);
135
188
  }
136
189
  return config.profiles[0];
137
190
  }
138
191
  const profile = config.profiles.find((p) => p.name === targetName);
139
192
  if (!profile)
140
- throw new Error(`Profile "${targetName}" not found`);
193
+ throw new OperatorError(`Profile "${targetName}" not found`);
141
194
  return profile;
142
195
  }