ssh-mcp 2.2.6 → 2.3.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
@@ -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,42 @@ 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 config decides which hosts, roles and policy rules this server honours, so it checks
55
+ that nobody but you can read it — and treats the two platforms differently, because the
56
+ question has a much clearer answer on one of them.
57
+
58
+ **Linux and macOS: enforced.** The mode check above, on the file *and* the directory —
59
+ which is why `chmod 700` is in that command, since `mkdir -p` under the default umask
60
+ leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
61
+ unambiguous here and `chmod` is a one-line fix.
62
+
63
+ **Windows: reported.** There are no mode bits, so it reads the ACL and tells you what it
64
+ grants — a config under `%APPDATA%` inherits access for you, `SYSTEM` and `Administrators`
65
+ and needs nothing done to it, while one created elsewhere does not: a file under `C:\`
66
+ inherits read for every local account and modify for every authenticated one. The message
67
+ names the two `icacls` commands that fix it, and then the config loads anyway.
68
+
69
+ Advisory rather than enforced because enforced was tried first: 2.3.0 refused, and it
70
+ blocked a config at the documented `%APPDATA%` location whose ACL carried a principal the
71
+ allowlist had never seen ([#138](https://github.com/tufantunc/ssh-mcp/issues/138)). A check
72
+ whose worst outcome is locking you out of your own config is not worth that. Pass
73
+ `--strictConfigAcl` to refuse instead; under it, `--allowUncheckedConfigAcl` still loads a
74
+ config whose ACL could not be determined at all.
75
+
76
+ ### Exit statuses
77
+
78
+ | Status | Meaning |
79
+ |---|---|
80
+ | `0` | Clean shutdown |
81
+ | `1` | A defect in the server — printed with a stack trace; please report it |
82
+ | `2` | How it was invoked or configured — printed as a message, no stack |
83
+
84
+ A supervisor that treats any non-zero status as a failure needs no change. One
85
+ that matched on `1` to detect a startup problem should match on `2` as well.
86
+
48
87
  ### 3. Set credentials via environment variables
49
88
 
50
89
  ```bash
@@ -479,7 +518,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability report
479
518
  - [ ] Restrict network egress on target hosts
480
519
  - [ ] Use `readOnly = true` for monitoring profiles
481
520
  - [ ] Review audit logs regularly
482
- - [ ] Run `chmod 600 config.toml`
521
+ - [ ] Run `chmod 700 <config dir> && chmod 600 config.toml` (Windows: the ACL under `%APPDATA%` is already restricted)
483
522
 
484
523
  ---
485
524
 
@@ -547,7 +586,7 @@ Secrets are **never** passed as CLI arguments.
547
586
 
548
587
  | Flag | Default | Description |
549
588
  |------|---------|-------------|
550
- | `--config` | XDG path | Path to TOML config file |
589
+ | `--config` | platform config dir (see [Configure](#2-configure)) | Path to TOML config file |
551
590
  | `--host` | — | Quick start: SSH host (creates single-profile config) |
552
591
  | `--user` | — | Quick start: SSH username |
553
592
  | `--port` | 22 | Quick start: SSH port |
@@ -565,6 +604,8 @@ Secrets are **never** passed as CLI arguments.
565
604
  | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
566
605
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
567
606
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
607
+ | `--allowUncheckedConfigAcl` | false | Windows: load the config even if its ACL could not be read |
608
+ | `--strictConfigAcl` | Windows: refuse to start on a broad or unreadable config ACL instead of reporting it |
568
609
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
569
610
  | `--opaUrl` | — | OPA sidecar URL for external policy |
570
611
  | `--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
  }