ssh-mcp 2.2.3 → 2.2.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.
@@ -19,7 +19,7 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
19
19
  const READ_ONLY_ALLOWLIST = new Set([
20
20
  'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
21
21
  'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
22
- 'env', 'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
22
+ 'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
23
23
  'which', 'whereis', 'file', 'readlink', 'realpath', 'basename', 'dirname',
24
24
  'seq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm',
25
25
  'systemctl status', 'journalctl', 'docker ps', 'docker logs', 'docker inspect',
@@ -28,6 +28,15 @@ const READ_ONLY_ALLOWLIST = new Set([
28
28
  'host', 'ping', 'traceroute', 'git status', 'git log',
29
29
  'git diff', 'git branch', 'git show', 'git remote',
30
30
  ]);
31
+ // Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
32
+ // runs <cmd>, so allowlisting the name `env` vouched for a command the
33
+ // classifier never looked at — `env sudo rm -f /etc/passwd` classified
34
+ // `read-only` and ran on a profile whose whole contract is that it cannot
35
+ // write. It carries no shell metacharacter, so the SHELL_CONTROL_CHARS gate
36
+ // below did not catch it either. Falls through to `safe`, so run-command can
37
+ // still reach it under policy; a bare `env` that only prints the environment
38
+ // loses read-only status with it, which is the price of a name-based allowlist.
39
+ //
31
40
  // Deliberately NOT read-only: `curl` and `wget` fetch arbitrary URLs (SSRF to
32
41
  // cloud metadata / internal services), post local files to a remote host
33
42
  // (`curl -d @/etc/passwd`), and write remote files (`curl -o`, `wget -O`) —
@@ -99,6 +108,50 @@ const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff',
99
108
  /** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
100
109
  const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
101
110
  const PRIVILEGE_PREFIXES = new Set(['sudo', 'su', 'doas', 'pkexec']);
111
+ /**
112
+ * Binaries whose job is to run another command.
113
+ *
114
+ * The allowlist and the privilege check both name a binary, so a wrapper hides
115
+ * whatever it wraps: `env sudo id` was classified by the name `env`. These are
116
+ * stepped over when deciding whether a segment elevates, so the check sees the
117
+ * command that will actually run.
118
+ *
119
+ * `env` is the one that was also allowlisted, and it has been removed from
120
+ * READ_ONLY_ALLOWLIST above. The rest were never read-only, so before this they
121
+ * hid elevation rather than granting it — `nohup sudo systemctl stop nginx`
122
+ * classified `safe`, which the default bindings grant to admin and operator on
123
+ * every tier.
124
+ */
125
+ const EXEC_WRAPPERS = new Set([
126
+ 'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
127
+ 'timeout', 'chrt', 'taskset', 'xargs', 'watch',
128
+ ]);
129
+ /**
130
+ * Arguments that turn an allowlisted binary into one that writes or executes.
131
+ *
132
+ * The allowlist vouches for a name; these are the flags that make the name a
133
+ * lie. `find /var/www -delete` removes a directory tree and `find / -exec sudo
134
+ * id +` runs a command as root, and both classified `read-only` — the `-exec …
135
+ * \;` form only escaped because `;` happens to be a shell metacharacter, while
136
+ * the `+` terminator carries none.
137
+ */
138
+ const DISQUALIFYING_ARGS = {
139
+ find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
140
+ };
141
+ /** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
142
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
143
+ /** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
144
+ const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
145
+ /**
146
+ * Remove the quoting a shell would remove before looking up a command.
147
+ *
148
+ * `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
149
+ * suppresses alias expansion — but a verbatim string comparison sees three
150
+ * different words. Without this, a one-character edit walks around the check.
151
+ */
152
+ function unquote(word) {
153
+ return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
154
+ }
102
155
  /**
103
156
  * Privilege-prefix flags that consume the next argument, so `sudo -u root
104
157
  * reboot` is not read as invoking `root`. Enumerable because it is one tool's
@@ -134,6 +187,57 @@ function parseSegments(command) {
134
187
  }
135
188
  return segments;
136
189
  }
190
+ /**
191
+ * Whether a segment asks for elevation, reading past anything that is not yet
192
+ * the command.
193
+ *
194
+ * A shell resolves the command word after assignments, and a wrapper runs what
195
+ * follows it, so all of these execute sudo while naming something else first:
196
+ *
197
+ * env sudo id nohup sudo id timeout 5 sudo id
198
+ * FOO=1 sudo id nice -n 10 sudo id command sudo id
199
+ *
200
+ * The scan walks left to right and stops at the first word that is a real
201
+ * command. Reaching a privilege prefix before that is elevation; reaching
202
+ * anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
203
+ * mention rather than an invocation.
204
+ */
205
+ function segmentElevates(words) {
206
+ for (const raw of words) {
207
+ const word = stripPath(unquote(raw));
208
+ if (PRIVILEGE_PREFIXES.has(word))
209
+ return true;
210
+ if (ASSIGNMENT.test(raw))
211
+ continue;
212
+ if (EXEC_WRAPPERS.has(word))
213
+ continue;
214
+ if (raw.startsWith('-'))
215
+ continue;
216
+ if (BARE_NUMERIC.test(raw))
217
+ continue;
218
+ return false;
219
+ }
220
+ return false;
221
+ }
222
+ /**
223
+ * Does this command elevate anywhere a shell would act on it?
224
+ *
225
+ * Replaces four `^`-anchored regexes that only saw a *leading* prefix. Elevation
226
+ * behind a wrapper, behind an assignment, or after a separator all reached root
227
+ * with the command classified `safe` or `read-only`.
228
+ */
229
+ function elevates(command) {
230
+ return command
231
+ .split(/[;&|\n]/)
232
+ .some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
233
+ }
234
+ /** An allowlisted binary carrying a flag that makes it write or execute. */
235
+ function hasDisqualifyingArgs(command) {
236
+ return parseSegments(command).some(({ head, args }) => {
237
+ const rule = DISQUALIFYING_ARGS[head];
238
+ return rule !== undefined && args.some((arg) => rule.test(arg));
239
+ });
240
+ }
137
241
  function invokedWords(command) {
138
242
  const invoked = [];
139
243
  for (const { head, args } of parseSegments(command)) {
@@ -230,7 +334,7 @@ const DESTRUCTIVE_PATTERNS = [
230
334
  function isDestructive(command) {
231
335
  return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
232
336
  }
233
- const PRIVILEGED_INDICATORS = [
337
+ const LEADING_PRIVILEGE_PREFIXES = [
234
338
  /^\s*sudo\b/,
235
339
  /^\s*su\b/,
236
340
  /^\s*doas\b/,
@@ -238,7 +342,7 @@ const PRIVILEGED_INDICATORS = [
238
342
  ];
239
343
  export function extractBinary(command) {
240
344
  let cmd = command.trim();
241
- for (const prefix of PRIVILEGED_INDICATORS) {
345
+ for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
242
346
  cmd = cmd.replace(prefix, '').trim();
243
347
  }
244
348
  if (cmd.startsWith('-c ')) {
@@ -251,11 +355,10 @@ export function classifyCommand(command) {
251
355
  const trimmed = command.trim();
252
356
  const binary = extractBinary(trimmed);
253
357
  const fullCommand = trimmed;
254
- const isPrivileged = PRIVILEGED_INDICATORS.some((re) => re.test(trimmed));
255
- if (isPrivileged) {
358
+ if (elevates(trimmed)) {
256
359
  return { binary, fullCommand, class: 'privileged' };
257
360
  }
258
- if (isDestructive(trimmed)) {
361
+ if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
259
362
  return { binary, fullCommand, class: 'destructive' };
260
363
  }
261
364
  const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.2.3",
4
+ "version": "2.2.4",
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": {