ssh-mcp 2.3.2 → 2.3.4

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
@@ -138,7 +138,7 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
138
138
  | `list-connections` | Discover available hosts and connection status | ✅ | — |
139
139
  | `list-sessions` | List active sessions per host | ✅ | — |
140
140
  | `open-session` | Create a named interactive (stateful) or background session | — | — |
141
- | `close-session` | Close a session, releasing resources | — | ✅ |
141
+ | `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
142
142
  | `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
143
143
  | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
144
144
  | `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
package/build/cli.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The command line: argv into an AppConfig.
3
+ *
4
+ * Split out of index.ts, which runs `main()` at import time unless
5
+ * `SSH_MCP_DISABLE_MAIN=1` is set *and* `SSH_MCP_TEST` is not `1` — the gate is a
6
+ * disjunction, so the test variable overrides the disable one. That made every test of a
7
+ * pure argv helper a test that could start connecting to real hosts on a machine with a
8
+ * real config; one of them carried a six-line comment and a `vi.stubEnv` about exactly
9
+ * that, both removed by this split. Nothing in this file has a side effect at import
10
+ * time, so importing it is safe.
11
+ *
12
+ * Not *everything* argv-shaped lives here: index.ts still reads the transport, OTEL and
13
+ * OPA flags at the wiring site, where they are used. What moved is the part that had to
14
+ * escape the boot gate to be testable.
15
+ */
16
+ import { loadConfig, getConfigPath } from './config/loader.js';
17
+ import { OperatorError, ConfigNotFoundError } from './errors.js';
18
+ import { HOST_GROUPS } from './policy/engine.js';
19
+ export function parseArgv(args = process.argv.slice(2)) {
20
+ const config = {};
21
+ for (const arg of args) {
22
+ if (arg.startsWith('--')) {
23
+ const eq = arg.indexOf('=');
24
+ if (eq === -1) {
25
+ config[arg.slice(2)] = null;
26
+ }
27
+ else {
28
+ config[arg.slice(2, eq)] = arg.slice(eq + 1);
29
+ }
30
+ }
31
+ }
32
+ return config;
33
+ }
34
+ /**
35
+ * Whether a boolean flag was passed at all.
36
+ *
37
+ * `parseArgv` stores `null` for a flag written without `=`, which is how every
38
+ * documented boolean flag is written. Testing that value for truthiness makes
39
+ * the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
40
+ * `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
41
+ * nothing documents (#91). The two call sites that got it right already used
42
+ * `!== undefined`; this gives the check a name so the next one cannot miss it.
43
+ *
44
+ * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
45
+ * turned off once written into a wrapper script is its own annoyance.
46
+ */
47
+ export function flagEnabled(argv, name) {
48
+ if (!(name in argv))
49
+ return false;
50
+ const value = argv[name];
51
+ if (value === null || value === '')
52
+ return true;
53
+ return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
54
+ }
55
+ /**
56
+ * v1 flags that v2 removed. Silently ignoring them means the user only finds
57
+ * out at the first command, as an auth failure with no hint about the cause.
58
+ */
59
+ const REMOVED_V1_FLAGS = {
60
+ password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
61
+ suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
62
+ sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
63
+ disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
64
+ };
65
+ export function checkRemovedFlags(argv) {
66
+ const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
67
+ if (found.length === 0)
68
+ return;
69
+ throw new OperatorError('These flags were removed in v2:\n' +
70
+ found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
71
+ '\nSee the "Migrating from v1" section of the README.');
72
+ }
73
+ /**
74
+ * v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
75
+ * with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
76
+ */
77
+ export function parseMaxChars(raw) {
78
+ if (typeof raw !== 'string' || raw === '')
79
+ return 5_000;
80
+ if (raw.toLowerCase() === 'none')
81
+ return Number.MAX_SAFE_INTEGER;
82
+ const parsed = parseInt(raw);
83
+ if (isNaN(parsed))
84
+ return 5_000;
85
+ return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
86
+ }
87
+ /**
88
+ * `strict` was previously unreachable: nothing but test code could select it,
89
+ * yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
90
+ * existed. `--insecureHostKey` is kept as an alias for the documented flag.
91
+ */
92
+ export function resolveHostKeyMode(argv) {
93
+ if (flagEnabled(argv, 'insecureHostKey'))
94
+ return 'insecure';
95
+ const mode = argv.hostKeyMode;
96
+ if (mode === null || mode === undefined)
97
+ return 'tofu';
98
+ if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
99
+ return mode;
100
+ throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
101
+ }
102
+ /**
103
+ * Which policy tier a CLI-configured host belongs to.
104
+ *
105
+ * Without this there was no way to say it from the command line at all. The
106
+ * inline profile carries no group, so it fell to the strictest tier, where the
107
+ * `admin` role has no `privileged` — meaning `sudo` could never run for anyone
108
+ * who had not written a config file (#91).
109
+ *
110
+ * The default stays `prod`. Guessing an unknown host is production is the safe
111
+ * direction; what was missing was a way to correct the guess.
112
+ */
113
+ export function resolveHostGroup(argv) {
114
+ const group = argv.group;
115
+ if (group === null || group === undefined)
116
+ return 'prod';
117
+ if (HOST_GROUPS.includes(group))
118
+ return group;
119
+ // Falling through would silently apply the prod bindings to a typo, and the
120
+ // operator would read the refusal as policy rather than as their own slip.
121
+ throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
122
+ }
123
+ export async function buildAppConfig(argv) {
124
+ // The way past an ACL that could not be read, rather than an operator with no
125
+ // exit. See assertPrivateOnWindows.
126
+ // The sink is supplied here rather than left to the module's default, which is the
127
+ // point of having one: "this security check did not run" is a decision about what the
128
+ // operator must be told, and the loader is not the layer that owns that. Routing it to
129
+ // the audit store as well needs an AuditRecord variant for a startup event — that type
130
+ // is hash-chained and tamper-evident, so it is its own change, and it is queued.
131
+ //
132
+ // An earlier version also pushed every finding into a module-level array "so main() can
133
+ // act on them". Nothing ever read it, and this split made the claim impossible — main()
134
+ // is in another module and the array was not exported. Re-adding a collection point is
135
+ // one line when there is a consumer to write it against.
136
+ const aclOpts = {
137
+ enforce: flagEnabled(argv, 'strictConfigAcl'),
138
+ allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
139
+ onFinding: (f) => {
140
+ console.error(`Warning: ${f.message}`);
141
+ },
142
+ };
143
+ if (argv.config !== undefined) {
144
+ // `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
145
+ // be falsy here and fall into the default-path branch — which now refuses rather than
146
+ // silently ignoring an unusable file, so the operator got a refusal naming a path they
147
+ // never asked for.
148
+ if (argv.config === null || argv.config === '') {
149
+ throw new OperatorError('--config needs a path: --config=<path>');
150
+ }
151
+ return loadConfig(argv.config, aclOpts);
152
+ }
153
+ try {
154
+ const fromFile = await loadConfig(undefined, aclOpts);
155
+ // Before #138 every Windows config was rejected by the mode-bit check and
156
+ // the rejection was swallowed, so an operator who also passed --host/--user
157
+ // was silently running off the flags. Now that the file loads, it takes
158
+ // precedence — a flip worth one line rather than a surprise about which host
159
+ // a command reached.
160
+ if (argv.host || argv.user) {
161
+ console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
162
+ 'Pass --config <path> to choose a different file.');
163
+ }
164
+ return fromFile;
165
+ }
166
+ catch (err) {
167
+ // Only "there is no file" means fall through. A file that exists and is
168
+ // unusable — malformed TOML, a schema violation, a permission failure —
169
+ // used to be swallowed here too and reported as a missing config, which is
170
+ // how #138 stayed unfindable: the operator's file was in the right place,
171
+ // and the real error was discarded before anyone could read it.
172
+ //
173
+ // Falling through would also be wrong on its own terms whenever
174
+ // --host/--user happen to be present: the server would start, silently
175
+ // ignoring every profile, policy and role binding the operator wrote.
176
+ if (!(err instanceof ConfigNotFoundError))
177
+ throw err;
178
+ }
179
+ 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).');
185
+ }
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
+ };
198
+ const profile = {
199
+ name: 'default',
200
+ host: argv.host,
201
+ port: parseInt(argv.port) || 22,
202
+ user: argv.user,
203
+ auth: argv.key ? 'key' : 'password',
204
+ keyRef: argv.key || undefined,
205
+ workdir: argv.workdir || undefined,
206
+ tty: false,
207
+ timeout: defaults.commandTimeoutMs,
208
+ maxChars: defaults.commandMaxChars,
209
+ maxOutputBytes: defaults.commandMaxOutputBytes,
210
+ role: 'admin',
211
+ group: resolveHostGroup(argv),
212
+ readOnly: false,
213
+ approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
214
+ cert: false,
215
+ sessionMaxPerConnection: defaults.sessionMaxPerConnection,
216
+ sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
217
+ sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
218
+ commandQuotaPerDay: defaults.commandQuotaPerDay,
219
+ };
220
+ return { defaults, profiles: [profile] };
221
+ }