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 +45 -4
- package/build/config/credential-resolver.js +3 -2
- package/build/config/loader.js +79 -26
- package/build/config/windows-acl.js +791 -0
- package/build/errors.js +116 -0
- package/build/index.js +65 -13
- package/build/policy/engine.js +3 -2
- package/build/ssh/host-key.js +3 -2
- package/build/transport/http.js +10 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -23,7 +23,13 @@ npm install -g ssh-mcp
|
|
|
23
23
|
|
|
24
24
|
### 2. Configure
|
|
25
25
|
|
|
26
|
-
Create
|
|
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` |
|
|
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
|
|
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
|
|
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.`);
|
package/build/config/loader.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
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.
|
|
99
|
-
// (`maxChars: defaults.commandMaxChars`), and was correct only
|
|
100
|
-
// path's value comes from parseMaxChars, which never returns 0.
|
|
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
|
|
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
|
|
193
|
+
throw new OperatorError(`Profile "${targetName}" not found`);
|
|
141
194
|
return profile;
|
|
142
195
|
}
|