ssh-mcp 2.2.1 → 2.2.3

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
@@ -150,7 +150,7 @@ sessionMaxPerConnection = 5
150
150
  sessionIdleTimeoutMs = 600000 # 10min
151
151
  sessionBackgroundMaxMs = 3600000 # 1hr
152
152
  commandTimeoutMs = 60000
153
- commandMaxChars = 5000
153
+ commandMaxChars = 5000 # 0 = unlimited, the config spelling of --maxChars=none
154
154
  commandMaxOutputBytes = 1048576 # 1MB
155
155
  connectionIdleReapMs = 900000 # 15min
156
156
  commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
@@ -177,6 +177,7 @@ cert = false # SSH CA cert auth — auto-detects keyRef-c
177
177
  sessionMaxPerConnection = 3 # per-profile override
178
178
  sessionIdleTimeoutMs = 300000 # stricter for prod
179
179
  commandQuotaPerDay = 200 # per-profile override
180
+ maxChars = 2000 # per-profile override; stricter for prod
180
181
 
181
182
  # Optional. Merged over the built-in role matrix; see "Policy Engine" below.
182
183
  # roleBindings is keyed by role and then by tier, so the block below changes
@@ -378,6 +379,19 @@ distinguishable from a fresh human answer.
378
379
  **Off by default** (`0` = always prompt). Auto-approval weakens the gate that
379
380
  makes destructive commands safe, so turning it on should be a decision.
380
381
 
382
+ #### Answering the prompt
383
+
384
+ Approval goes through the MCP elicitation request, so what you see is your
385
+ client's dialog. Accepting it approves the command — there is no second field to
386
+ fill in.
387
+
388
+ You have **10 minutes** to answer. Past that the request expires and the command
389
+ is refused rather than left pending, and the refusal says so; the prompt may
390
+ still be open in your client, in which case run the command again once you are
391
+ ready. If your client does not support elicitation at all, every destructive and
392
+ privileged command is refused with `APPROVAL_UNAVAILABLE` naming that cause —
393
+ approval fails closed by design.
394
+
381
395
  ### Command Quota
382
396
 
383
397
  `commandQuotaPerDay` bounds how many commands a profile may run in a rolling
@@ -541,7 +555,7 @@ Secrets are **never** passed as CLI arguments.
541
555
  | `--workdir` | — | Quick start: Working directory for commands and sessions |
542
556
  | `--group` | prod | Quick start: Policy tier — `prod`, `staging` or `dev` |
543
557
  | `--timeout` | 60000 | Command timeout in ms |
544
- | `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
558
+ | `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit; in a config file the same setting is `commandMaxChars = 0`) |
545
559
  | `--sessionMax` | 5 | Max concurrent sessions per connection |
546
560
  | `--sessionTtl` | 600000 | Session idle timeout in ms |
547
561
  | `--transport` | stdio | `stdio` or `http` |
@@ -67,6 +67,22 @@ export async function loadConfig(customPath) {
67
67
  }
68
68
  return normalizeConfig(result.data);
69
69
  }
70
+ /**
71
+ * `0` means unlimited, the convention `commandQuotaPerDay` and
72
+ * `approvalGrantTtlMs` already use in this config file.
73
+ *
74
+ * It has to be *mapped* rather than merely permitted. `sanitizeCommand` tests
75
+ * `cleaned.length > maxChars`, so a literal `0` arriving there rejects every
76
+ * non-empty command with "Command is too long (max 0 characters)" — a worse
77
+ * failure than the one this spelling exists to fix.
78
+ *
79
+ * MAX_SAFE_INTEGER is the value `parseMaxChars` produces for `--maxChars=none`,
80
+ * so the flag and the config file hand the rest of the code an identical
81
+ * Profile rather than two spellings of uncapped (#123).
82
+ */
83
+ function uncapZero(maxChars) {
84
+ return maxChars === 0 ? Number.MAX_SAFE_INTEGER : maxChars;
85
+ }
70
86
  /**
71
87
  * Resolve every profile against [defaults]. Each of these keys is documented as
72
88
  * a default that profiles inherit, so all of them must cascade — a key that is
@@ -75,13 +91,23 @@ export async function loadConfig(customPath) {
75
91
  * downgrade, not just a papercut).
76
92
  */
77
93
  function normalizeConfig(raw) {
94
+ // commandMaxChars is mapped here as well as on the profile below, so no `0`
95
+ // survives anywhere in AppConfig. Leaving the raw value on `defaults` left one
96
+ // object carrying two encodings of "unlimited" — `profile.maxChars` as
97
+ // 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
101
+ // coincidence of another surface is not a property to leave standing when the
102
+ // wrong encoding rejects every non-empty command.
78
103
  const defaults = {
79
104
  ...raw.defaults,
105
+ commandMaxChars: uncapZero(raw.defaults.commandMaxChars),
80
106
  };
81
107
  const profiles = raw.profiles.map((p) => ({
82
108
  ...p,
83
109
  timeout: p.timeout ?? defaults.commandTimeoutMs,
84
- maxChars: p.maxChars ?? defaults.commandMaxChars,
110
+ maxChars: uncapZero(p.maxChars ?? defaults.commandMaxChars),
85
111
  maxOutputBytes: p.maxOutputBytes ?? defaults.commandMaxOutputBytes,
86
112
  approvalPolicy: p.approvalPolicy ?? defaults.approvalMode,
87
113
  sessionMaxPerConnection: p.sessionMaxPerConnection ?? defaults.sessionMaxPerConnection,
@@ -18,7 +18,10 @@ export const defaultsSchema = z.object({
18
18
  sessionIdleTimeoutMs: z.number().int().positive().default(600_000),
19
19
  sessionBackgroundMaxMs: z.number().int().positive().default(3_600_000),
20
20
  commandTimeoutMs: z.number().int().positive().default(60_000),
21
- commandMaxChars: z.number().int().positive().default(5000),
21
+ // 0 = unlimited, matching `--maxChars=none` on the CLI. normalizeConfig()
22
+ // maps it to Number.MAX_SAFE_INTEGER so both surfaces hand the rest of the
23
+ // code an identical Profile.
24
+ commandMaxChars: z.number().int().nonnegative().default(5000),
22
25
  commandMaxOutputBytes: z.number().int().positive().default(1_048_576),
23
26
  connectionIdleReapMs: z.number().int().positive().default(900_000),
24
27
  // 0 = unlimited. A circuit breaker for runaway agents, not a rate limit.
@@ -50,7 +53,8 @@ export const profileSchema = z.object({
50
53
  // a schema-level .default() would make "user omitted it" indistinguishable
51
54
  // from "user set the default value", silently shadowing [defaults].
52
55
  timeout: z.number().int().positive().optional(),
53
- maxChars: z.number().int().positive().optional(),
56
+ // 0 = unlimited, as in [defaults].commandMaxChars above.
57
+ maxChars: z.number().int().nonnegative().optional(),
54
58
  maxOutputBytes: z.number().int().positive().optional(),
55
59
  approvalPolicy: approvalModeSchema.optional(),
56
60
  sessionMaxPerConnection: z.number().int().positive().optional(),
@@ -1,3 +1,17 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ /**
3
+ * How long a person has to read a destructive command and decide.
4
+ *
5
+ * Explicit because the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 seconds, and
6
+ * inheriting it here set a human's reading time from a default chosen for
7
+ * machine round trips. An operator who stepped away, or who was working out an
8
+ * unfamiliar dialog, had the request expire underneath them and the command
9
+ * refused (#91).
10
+ *
11
+ * Ten minutes is long enough to be interrupted and come back, and still bounded
12
+ * so a client that never answers cannot pin a tool call open forever.
13
+ */
14
+ export const APPROVAL_TIMEOUT_MS = 600_000;
1
15
  /**
2
16
  * Ask the MCP client to confirm a destructive or privileged command.
3
17
  *
@@ -6,8 +20,9 @@
6
20
  * of silently disabling the gate that guards every destructive command.
7
21
  *
8
22
  * Fails closed: any error (client without elicitation support, transport
9
- * failure, malformed reply) denies the command. The failure is logged, because
10
- * a gate that always denies for an unnoticed reason is its own kind of outage.
23
+ * failure, timeout, malformed reply) denies the command. The failure is logged,
24
+ * because a gate that always denies for an unnoticed reason is its own kind of
25
+ * outage.
11
26
  */
12
27
  export async function requestApproval(server, command, profileName, evaluation) {
13
28
  const message = `Confirm ${evaluation.commandClass} command on "${profileName}":\n\n${command}`;
@@ -22,26 +37,47 @@ export async function requestApproval(server, command, profileName, evaluation)
22
37
  title: 'Approve this command?',
23
38
  },
24
39
  },
25
- required: ['confirm'],
40
+ // `confirm` is deliberately not required. The protocol already carries
41
+ // the decision in `action`, where accept and decline are distinct
42
+ // results, so requiring a boolean on top asked the user to say the
43
+ // same thing twice — and clients render a required boolean as a
44
+ // checkbox beside the accept/decline row. Choosing Accept without
45
+ // ticking it submits a form missing a required field, so the client
46
+ // can produce no valid result and sends `cancel`, which arrived here
47
+ // as "User did not approve this command". The user picked Approve and
48
+ // was told they had declined (#91).
26
49
  },
27
- });
28
- if (result.action === 'accept' && result.content?.confirm === true) {
50
+ }, { timeout: APPROVAL_TIMEOUT_MS });
51
+ // `action` decides. An explicit `confirm: false` from a client that still
52
+ // sends the field is honoured, because refusing to run on an explicit "no"
53
+ // costs nothing and reading it as approval would be indefensible.
54
+ if (result.action === 'accept' && result.content?.confirm !== false) {
29
55
  return { approved: true, approver: 'mcp-client' };
30
56
  }
31
57
  return { approved: false };
32
58
  }
33
59
  catch (err) {
60
+ const timedOut = err instanceof McpError && err.code === ErrorCode.RequestTimeout;
34
61
  const cause = err instanceof Error ? err.message : String(err);
35
62
  // stderr still gets it for the operator's logs, but it cannot be the only
36
63
  // copy: for a stdio MCP server that is a client log file nobody reads, so
37
64
  // the caller gets the diagnosis too and can put it in front of the user.
38
65
  console.error(`Approval request failed for ${evaluation.commandClass} command on "${profileName}" — denying. ` +
39
- `Does this client support elicitation? Cause: ${cause}`);
66
+ `${timedOut ? `No answer within ${APPROVAL_TIMEOUT_MS / 1000}s.` : 'Does this client support elicitation?'} ` +
67
+ `Cause: ${cause}`);
68
+ // Naming the cause rather than guessing at it. This message was introduced
69
+ // to stop APPROVAL_DENIED covering two different failures, and then led with
70
+ // "a client without elicitation support" for every error the catch saw,
71
+ // timeouts included — the same defect, inside its own fix.
40
72
  return {
41
73
  approved: false,
42
- unavailable: `this MCP client could not be asked to approve it. The likely cause is a client without ` +
43
- `elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
44
- `closed, so the command was refused rather than run. Underlying error: ${cause}`,
74
+ unavailable: timedOut
75
+ ? `no answer arrived within ${APPROVAL_TIMEOUT_MS / 1000}s, so it was refused rather than left ` +
76
+ `pending. The prompt may still be open in your client; approve the command again once you are ` +
77
+ `ready to answer it.`
78
+ : `this MCP client could not be asked to approve it. The likely cause is a client without ` +
79
+ `elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
80
+ `closed, so the command was refused rather than run. Underlying error: ${cause}`,
45
81
  };
46
82
  }
47
83
  }
package/build/index.js CHANGED
@@ -24,6 +24,27 @@ function parseArgv() {
24
24
  }
25
25
  return config;
26
26
  }
27
+ /**
28
+ * Whether a boolean flag was passed at all.
29
+ *
30
+ * `parseArgv` stores `null` for a flag written without `=`, which is how every
31
+ * documented boolean flag is written. Testing that value for truthiness makes
32
+ * the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
33
+ * `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
34
+ * nothing documents (#91). The two call sites that got it right already used
35
+ * `!== undefined`; this gives the check a name so the next one cannot miss it.
36
+ *
37
+ * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
38
+ * turned off once written into a wrapper script is its own annoyance.
39
+ */
40
+ function flagEnabled(argv, name) {
41
+ if (!(name in argv))
42
+ return false;
43
+ const value = argv[name];
44
+ if (value === null || value === '')
45
+ return true;
46
+ return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
47
+ }
27
48
  /**
28
49
  * v1 flags that v2 removed. Silently ignoring them means the user only finds
29
50
  * out at the first command, as an auth failure with no hint about the cause.
@@ -62,7 +83,7 @@ function parseMaxChars(raw) {
62
83
  * existed. `--insecureHostKey` is kept as an alias for the documented flag.
63
84
  */
64
85
  function resolveHostKeyMode(argv) {
65
- if (argv.insecureHostKey !== undefined)
86
+ if (flagEnabled(argv, 'insecureHostKey'))
66
87
  return 'insecure';
67
88
  const mode = argv.hostKeyMode;
68
89
  if (mode === null || mode === undefined)
@@ -134,7 +155,7 @@ async function buildAppConfig(argv) {
134
155
  role: 'admin',
135
156
  group: resolveHostGroup(argv),
136
157
  readOnly: false,
137
- approvalPolicy: argv.disableApproval ? 'auto' : defaults.approvalMode,
158
+ approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
138
159
  cert: false,
139
160
  sessionMaxPerConnection: defaults.sessionMaxPerConnection,
140
161
  sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
@@ -152,8 +173,8 @@ async function main() {
152
173
  }
153
174
  const config = await buildAppConfig(argv);
154
175
  const hostKeyMode = resolveHostKeyMode(argv);
155
- const entropyScan = !!argv.auditEntropyScan;
156
- const tamperEvident = !!argv.auditTamperEvident;
176
+ const entropyScan = flagEnabled(argv, 'auditEntropyScan');
177
+ const tamperEvident = flagEnabled(argv, 'auditTamperEvident');
157
178
  await initKeychain();
158
179
  if (argv.otelEndpoint) {
159
180
  const { initTracing } = await import('./observability/tracer.js');
@@ -242,4 +263,4 @@ if (isCliEnabled || isTestMode) {
242
263
  process.exit(1);
243
264
  });
244
265
  }
245
- export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars };
266
+ export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
@@ -47,28 +47,38 @@ const READ_ONLY_ALLOWLIST = new Set([
47
47
  * purposes; see DESTRUCTIVE_DENYLIST below.
48
48
  */
49
49
  /*
50
- * Every `\s+` here is followed by a literal. Where a `.*` or `[^|]*` comes
51
- * next, the quantifier before it is a single `\s`: `\s+.*` lets both halves
52
- * claim the same run of spaces, so a non-matching command is retried from every
53
- * split and the match goes quadratic. sanitizeCommand caps commands at
54
- * profile.maxChars (5000 by default) long before this runs, which is what keeps
55
- * that cheap — but a policy check should not depend on a limit set three layers
56
- * away and configurable to any value.
50
+ * These are the patterns that stayed regexes, and every one of them is linear.
51
+ *
52
+ * The four that were not — `dd\s.*\bof=/dev/`, the two `curl|wget …\|\s*(sh…)`
53
+ * forms, and `chown\s+-R\s.*\s/\s*$` — are now segment checks below. An earlier
54
+ * comment here reasoned that ambiguity *within* a match was the only cost, and
55
+ * that was wrong: the engine also restarts at every offset where the cheap
56
+ * literal head matches, so a command built from `dd curl wget chown -R x `
57
+ * repeated ran at 4x per doubling. Measured on the real chain: 64 KB took
58
+ * 255 ms, 1 MB took 65 seconds of blocked event loop, and the stall lands in
59
+ * classifyCommand — before the approval gate and before the allow/deny
60
+ * decision, so no role, approval mode or readOnly flag protects against it.
61
+ *
62
+ * The old note said sanitizeCommand's maxChars cap kept this cheap, "but a
63
+ * policy check should not depend on a limit set three layers away and
64
+ * configurable to any value". Once a config file could say `commandMaxChars = 0`
65
+ * (#123) that limit went away, and the prediction came true. The rewrite below
66
+ * is what that sentence was asking for: the policy check is now safe on its own
67
+ * terms, whatever maxChars says.
68
+ *
69
+ * The existing cost test did not catch it because its seeds are runs of spaces,
70
+ * which never match the literal heads and so never trigger the restart.
57
71
  */
58
72
  const FORBIDDEN_PATTERNS = [
59
73
  /rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
60
74
  /mkfs\./,
61
- /dd\s.*\bof=\/dev\//,
62
75
  />\s*\/dev\/sd/,
63
76
  /:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
64
- /curl\s[^|]*\|\s*(sh|bash|zsh)/,
65
- /wget\s[^|]*\|\s*(sh|bash|zsh)/,
66
77
  />\s*\/etc\/cron/,
67
78
  />\s*\/etc\/systemd/,
68
79
  />\s*~\/.ssh\/authorized_keys/,
69
80
  /\biptables\s+-F\b/,
70
81
  /\bchmod\s+-R\s+777\s+\//,
71
- /\bchown\s+-R\s.*\s\/\s*$/,
72
82
  ];
73
83
  /**
74
84
  * Words that are forbidden when *invoked*, which the patterns above cannot
@@ -104,20 +114,12 @@ function stripPath(word) {
104
114
  const slash = word.lastIndexOf('/');
105
115
  return slash === -1 ? word : word.slice(slash + 1);
106
116
  }
107
- /**
108
- * The command words a shell would actually execute — the head of every
109
- * `;`/`&&`/`||`/`|`/newline-separated segment, past any privilege prefix, plus
110
- * the arguments of a multiplexer like `systemctl`.
111
- *
112
- * Pure string work: split, trim, set lookups. No quantifiers, so nothing here
113
- * can backtrack.
114
- */
115
- function invokedWords(command) {
116
- const invoked = [];
117
- for (const segment of command.split(/[;&|\n]/)) {
118
- const words = segment.trim().split(/\s+/).filter(Boolean).map(stripPath);
117
+ function parseSegments(command) {
118
+ const segments = [];
119
+ for (const raw of command.split(/[;&|\n]/)) {
120
+ const words = raw.trim().split(/\s+/).filter(Boolean);
119
121
  let i = 0;
120
- while (i < words.length && PRIVILEGE_PREFIXES.has(words[i])) {
122
+ while (i < words.length && PRIVILEGE_PREFIXES.has(stripPath(words[i]))) {
121
123
  i++;
122
124
  while (i < words.length && words[i].startsWith('-')) {
123
125
  const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
@@ -126,25 +128,73 @@ function invokedWords(command) {
126
128
  i++;
127
129
  }
128
130
  }
129
- const head = words[i];
130
- if (!head)
131
- continue;
131
+ if (i < words.length) {
132
+ segments.push({ head: stripPath(words[i]), args: words.slice(i + 1) });
133
+ }
134
+ }
135
+ return segments;
136
+ }
137
+ function invokedWords(command) {
138
+ const invoked = [];
139
+ for (const { head, args } of parseSegments(command)) {
132
140
  invoked.push(head);
133
141
  // `systemctl reboot` restarts the host. Reading a unit that happens to be
134
142
  // named after a power action is rare enough that erring towards refusal
135
143
  // here costs little.
136
144
  if (ACTION_MULTIPLEXERS.has(head)) {
137
- invoked.push(...words.slice(i + 1).filter((w) => !w.startsWith('-')));
145
+ invoked.push(...args.filter((w) => !w.startsWith('-')).map(stripPath));
138
146
  }
139
147
  }
140
148
  return invoked;
141
149
  }
150
+ const SHELLS = new Set(['sh', 'bash', 'zsh']);
151
+ const DOWNLOADERS = new Set(['curl', 'wget']);
152
+ /**
153
+ * A download piped into a shell.
154
+ *
155
+ * Split on `|` rather than reading the whole string, so cost is linear in the
156
+ * command's length no matter how the two halves are spaced. `||` produces an
157
+ * empty part between them, and this deliberately still matches: `curl x || sh`
158
+ * runs a shell when the download fails, which is not meaningfully safer than
159
+ * running one when it succeeds.
160
+ */
161
+ function pipesDownloadIntoShell(command) {
162
+ const heads = command.split('|').map((part) => parseSegments(part)[0]?.head);
163
+ const firstDownload = heads.findIndex((h) => h !== undefined && DOWNLOADERS.has(h));
164
+ if (firstDownload === -1)
165
+ return false;
166
+ return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
167
+ }
168
+ /** `dd … of=/dev/sda` — writing an image straight onto a block device. */
169
+ function writesToDevice(command) {
170
+ return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
171
+ }
172
+ /**
173
+ * `chown -R … /` — a recursive chown whose target is the filesystem root.
174
+ *
175
+ * The last non-flag argument is the target; `chown -R app:app /srv/app` is
176
+ * ordinary and stays allowed.
177
+ */
178
+ function chownsRoot(command) {
179
+ return parseSegments(command).some(({ head, args }) => {
180
+ if (head !== 'chown' || !args.includes('-R'))
181
+ return false;
182
+ const positional = args.filter((a) => !a.startsWith('-'));
183
+ return positional[positional.length - 1] === '/';
184
+ });
185
+ }
142
186
  const FORBIDDEN_RULES = [
143
187
  ...FORBIDDEN_PATTERNS.map((re) => ({ label: String(re), test: (c) => re.test(c) })),
144
188
  {
145
189
  label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
146
190
  test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
147
191
  },
192
+ {
193
+ label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
194
+ test: pipesDownloadIntoShell,
195
+ },
196
+ { label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
197
+ { label: 'a recursive chown of the filesystem root', test: chownsRoot },
148
198
  ];
149
199
  /**
150
200
  * Which never-allowed rule this command trips, or null. The single entry point:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.2.1",
4
+ "version": "2.2.3",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {