ssh-mcp 2.5.0 → 2.6.0

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
@@ -152,8 +152,8 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
152
152
  | `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
153
153
  | `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
154
154
  | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
155
- | `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
156
- | `privileged-command` | Execute with sudo (always requires approval) | — | ✅ |
155
+ | `run-command` | Execute arbitrary commands (destructive/privileged need approval, unless `approvalPolicy = "auto"`) | — | — |
156
+ | `privileged-command` | Execute with sudo (needs approval, unless `approvalPolicy = "auto"`) | — | ✅ |
157
157
  | `sftp-upload` | Upload a file via SFTP | — | ✅ |
158
158
  | `sftp-download` | Download a file via SFTP | ✅ | — |
159
159
  | `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
@@ -422,7 +422,13 @@ denylist; an invalid pattern fails at startup rather than degrading silently.
422
422
  ### Approval Modes
423
423
 
424
424
  - `auto` — no prompts (dev only!)
425
- - `ask-destructive` — prompt for destructive/privileged (default)
425
+ - `ask-destructive` — prompt for destructive/privileged (default). Narrower than it sounds:
426
+ outside the never-allowed list, `destructive` is one `rm -rf /path` pattern, `find` with a
427
+ write/exec flag, an unresolvable command word, a program handed to an interpreter this
428
+ server cannot read (`python3 -c`, `perl -e`, `node -e`, a program arriving on a pipe —
429
+ but not `awk`, whose program is not read), and `sftp-upload`/interactive `open-session` — elevation classifies `privileged`, which also
430
+ prompts. Ordinary writes, service control and signals do not. See
431
+ [SECURITY.md](./SECURITY.md) before relying on this in production.
426
432
  - `ask-all` — prompt for every command
427
433
  - `deny` — reject destructive/privileged commands outright (no prompt)
428
434
 
@@ -566,7 +572,7 @@ level number.
566
572
  ### Safe Defaults
567
573
 
568
574
  - **Non-root** user in all examples
569
- - **TOFU** host key verification (accept on first connect, verify after)
575
+ - **TOFU** host key verification (accept on first connect, verify after — within one process; see [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart))
570
576
  - **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
571
577
  - **exec()-only** (no persistent su shells — fixes PTY leak)
572
578
  - **Sudo via stdin** (not argv — fixes process list leak)
@@ -673,6 +679,7 @@ Secrets are **never** passed as CLI arguments.
673
679
  | `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
674
680
  | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
675
681
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
682
+ | `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) — `strict` currently refuses every host |
676
683
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
677
684
  | `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
678
685
  | `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
@@ -698,8 +705,8 @@ replacement, rather than failing later as a confusing auth error.
698
705
  | v1 | v2 | Notes |
699
706
  |----|----|-------|
700
707
  | `exec` | `read-command` | Allowlisted read-only commands. Prefer this for reads. |
701
- | `exec` | `run-command` | Arbitrary commands. Destructive ones go through the approval gate. |
702
- | `sudo-exec` | `privileged-command` | Always requires approval. Password is piped via stdin. |
708
+ | `exec` | `run-command` | Arbitrary commands. Destructive and privileged ones go through the approval gate, unless `approvalPolicy = "auto"`. |
709
+ | `sudo-exec` | `privileged-command` | Requires approval unless `approvalPolicy = "auto"`. Password is piped via stdin. |
703
710
  | `description` parameter | — | Removed. It was an injection vector (#44) and never reached the host. |
704
711
 
705
712
  **Command results now carry status.** In v1 a failed command rejected with
@@ -739,8 +746,12 @@ and pass `--config <path>`; profiles carry per-host roles and approval policy.
739
746
  ### Host key verification
740
747
 
741
748
  v1 did not verify host keys. v2 defaults to trust-on-first-use and records the
742
- key; a later mismatch fails the connection. Pin explicitly with `trustedHostKey`
743
- in a profile, or pass `--insecureHostKey` to opt out (test environments only).
749
+ key **in memory, for the life of the process**; a later mismatch in that same
750
+ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts`
751
+ is not consulted, so a restart accepts afresh — see
752
+ [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
753
+ explicitly with `trustedHostKey` in a profile, which is the only control here that
754
+ survives a restart, or pass `--insecureHostKey` to opt out (test environments only).
744
755
 
745
756
  ---
746
757
 
@@ -135,9 +135,45 @@ const CLASS_RANK = {
135
135
  privileged: 3,
136
136
  };
137
137
  /** Shells that run their next argument as a command when given `-c`. */
138
- const SHELL_BINARIES = new Set([
139
- 'sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'busybox',
140
- ]);
138
+ /**
139
+ * Interpreters, the flags that hand them a program, and whether this file can read it.
140
+ *
141
+ * One table rather than three overlapping name lists: adding an interpreter here is the
142
+ * whole change, and a shell list that drifts out of step with a readability list is how a
143
+ * new shell ends up treated as an unreadable interpreter.
144
+ *
145
+ * `readable` means the program is shell, so classifying it is this same job done again
146
+ * and the class it earns is real. Python, Perl, Ruby, Node and PHP are not: their program
147
+ * is a different language, and `os.system('sudo id')` carries an elevation the shell
148
+ * classifier cannot see. Pretending to parse them would be a worse answer than admitting
149
+ * we cannot, so an unreadable program makes the command `destructive` — "we cannot tell",
150
+ * not "this is root", the same answer `hasUnnameableCommand` gives.
151
+ *
152
+ * `busybox` is not here: it takes an applet name, not `-c`, so `busybox sh -c …` is an
153
+ * `sh` invocation behind a wrapper. It is in EXEC_WRAPPERS instead, which is what it is.
154
+ */
155
+ // Null-prototype for the reason `mergePolicyRules` spells out (#172): this is indexed by
156
+ // a command word, which is a free string, so on a plain object `INTERPRETERS['toString']`
157
+ // resolves to a function and reading `.flags` off it throws inside the policy gate.
158
+ const INTERPRETERS = Object.assign(Object.create(null), {
159
+ sh: { flags: ['-c'], readable: true },
160
+ bash: { flags: ['-c'], readable: true },
161
+ dash: { flags: ['-c'], readable: true },
162
+ zsh: { flags: ['-c'], readable: true },
163
+ ksh: { flags: ['-c'], readable: true },
164
+ ash: { flags: ['-c'], readable: true },
165
+ python: { flags: ['-c'], readable: false },
166
+ python2: { flags: ['-c'], readable: false },
167
+ python3: { flags: ['-c'], readable: false },
168
+ perl: { flags: ['-e', '-E'], readable: false },
169
+ ruby: { flags: ['-e'], readable: false },
170
+ // `-p`/`--print` evaluate exactly as `-e` does and then print the result.
171
+ node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
172
+ php: { flags: ['-r'], readable: false },
173
+ });
174
+ /** Flags whose value is a separate word, which is not the program. `awk -F ':' '{…}'`. */
175
+ /** `find … -exec <cmd> +` runs cmd. */
176
+ const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
141
177
  /**
142
178
  * How deep a substitution may nest before we stop reading and refuse to guess.
143
179
  *
@@ -147,39 +183,156 @@ const SHELL_BINARIES = new Set([
147
183
  */
148
184
  const MAX_NESTING_DEPTH = 8;
149
185
  /**
150
- * Split on whitespace the way a shell would, keeping quoted runs together.
186
+ * Split a command into segments of words, resolving quoting as a shell would.
151
187
  *
152
- * `parseSegments` splits on `/\s+/`, which turns `sh -c "sudo id"` into
153
- * `["sh", "-c", "\"sudo", "id\""]` — the argument is no longer a unit, so nothing
154
- * downstream can classify it as the command it is.
188
+ * The classifier's regexes were written against the command as sent, but the transport
189
+ * removes quoting before the command runs, so `rm -rf "/etc"` matched no destructive
190
+ * pattern and `s"u"do id` named no privilege prefix. Every consumer that used to split on
191
+ * `/[;&|\n]/` and `/\s+/` reads this instead, so quote removal happens once, in one
192
+ * place, rather than being re-derived per rule.
193
+ *
194
+ * Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
195
+ * `hasUnnameableCommand` is what refuses a command word this cannot resolve.
155
196
  */
156
- function splitRespectingQuotes(command) {
157
- const words = [];
197
+ function tokenizeSegments(command) {
198
+ return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
199
+ }
200
+ /**
201
+ * The same split, keeping the separator that introduced each segment.
202
+ *
203
+ * Only the pipe rules need it: `|` feeds one command's output into the next, while `;`
204
+ * and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
205
+ * Deriving both views from one tokeniser is what stops the two from drifting apart.
206
+ */
207
+ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
208
+ const segments = [];
209
+ let pending = '';
210
+ let words = [];
158
211
  let current = '';
159
212
  let quote = null;
213
+ let escaped = false;
214
+ const endWord = () => {
215
+ if (current)
216
+ words.push(current);
217
+ current = '';
218
+ };
219
+ const endSegment = (sep) => {
220
+ endWord();
221
+ if (words.length > 0)
222
+ segments.push({ words, sep: pending });
223
+ words = [];
224
+ pending = sep;
225
+ };
160
226
  for (const ch of command) {
227
+ if (escaped) {
228
+ // A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
229
+ // character and dropping the backslash is what the shell does.
230
+ current += ch;
231
+ escaped = false;
232
+ continue;
233
+ }
161
234
  if (quote) {
235
+ // Backslash is literal inside single quotes; inside double quotes it escapes.
236
+ if (ch === '\\' && quote === '"') {
237
+ escaped = true;
238
+ continue;
239
+ }
162
240
  if (ch === quote)
163
241
  quote = null;
164
242
  else
165
243
  current += ch;
166
244
  continue;
167
245
  }
168
- if (ch === '"' || ch === "'") {
246
+ if (ch === '\\') {
247
+ escaped = true;
248
+ continue;
249
+ }
250
+ if (honorQuotes && (ch === '"' || ch === "'")) {
169
251
  quote = ch;
170
252
  continue;
171
253
  }
254
+ if (ch === ';' || ch === '&' || ch === '|' || ch === '\n') {
255
+ endSegment(ch);
256
+ continue;
257
+ }
172
258
  if (/\s/.test(ch)) {
173
- if (current)
174
- words.push(current);
175
- current = '';
259
+ endWord();
176
260
  continue;
177
261
  }
178
262
  current += ch;
179
263
  }
180
- if (current)
181
- words.push(current);
182
- return words;
264
+ endSegment('');
265
+ // A quote left open at the end is not a command a shell would run — it is a syntax
266
+ // error. Reading it as one long quoted argument is the dangerous reading: `echo "hi;
267
+ // sudo id` then never splits on the `;` and the elevation disappears, which measured as
268
+ // `privileged` before this tokeniser and `safe` after. So fall back to the scan that
269
+ // treats the quote character as ordinary text, which splits and still sees `sudo id`.
270
+ if (quote !== null)
271
+ return tokenizeSegmentsDetailed(command, false);
272
+ return segments;
273
+ }
274
+ /**
275
+ * The command rebuilt from its tokens, for the regex rules to match against.
276
+ *
277
+ * Segments are rejoined with `; ` so a pattern cannot match across two commands that the
278
+ * shell would run separately.
279
+ */
280
+ let lastNormalizedInput = null;
281
+ let lastNormalizedOutput = '';
282
+ function normalizeCommand(command) {
283
+ // One evaluate asks for this about nineteen times, once per regex rule, and each ask
284
+ // re-ran the tokeniser over the whole command. The asks arrive in a row on the same
285
+ // input, so a one-entry cache removes almost all of it.
286
+ if (command === lastNormalizedInput)
287
+ return lastNormalizedOutput;
288
+ lastNormalizedInput = command;
289
+ lastNormalizedOutput = normalizeUncached(command);
290
+ return lastNormalizedOutput;
291
+ }
292
+ function normalizeUncached(command) {
293
+ return tokenizeSegments(command)
294
+ .map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
295
+ .join('; ');
296
+ }
297
+ /**
298
+ * A character the tokeniser splits on or at, which a token can only hold if it was quoted.
299
+ *
300
+ * The tokeniser splits on unquoted separators and at unquoted whitespace, so a token
301
+ * holding either came from inside quotes — the shell will not split there, and its contents are data. Rejoining it
302
+ * into the pattern text is what makes an argument read as a command: `echo "hello; rm -rf
303
+ * /"` prints a string, and normalising it produced `echo hello; rm -rf /`, which matched
304
+ * the never-allowed list. Dropping those tokens keeps quote removal doing the job it was
305
+ * added for — `rm -rf "/etc"`, whose tokens hold no separator — without letting a quoted
306
+ * argument impersonate a command. A carrier that really does hand over a quoted command
307
+ * (`sh -c "ls; rm -rf /etc"`) is read by `nestedCommands`, not by this.
308
+ */
309
+ const QUOTED_CONTENT = /[\s;&|\n]/;
310
+ /**
311
+ * What a quoted token becomes in the pattern text.
312
+ *
313
+ * Deleting it instead spliced its neighbours together and created an adjacency that appears
314
+ * nowhere in the command: `echo rm 'a|b' -rf /` normalised to `echo rm -rf /` and landed on
315
+ * the never-allowed list. A character no pattern contains and `\s` does not match stands in
316
+ * its place, so the two sides can no longer be read as one phrase.
317
+ */
318
+ const PLACEHOLDER = '\u0000';
319
+ /**
320
+ * Try a regex against the command as written and against the tokenised form.
321
+ *
322
+ * Normalising alone is not enough. It rebuilds the command from tokens, so the shell
323
+ * metacharacters between them are gone, and a pattern that matches across a separator no
324
+ * longer fires — the fork bomb, whose `:|:&` needs the literal `|` and `&`, is the case
325
+ * that shows this. Normalising is still what defeats `rm -rf "/etc"`. Neither form is a
326
+ * superset of the other, so both are tried: for a pattern whose whole job is to notice
327
+ * something, the union is the only direction that cannot lose a match.
328
+ *
329
+ * Deliberately scoped to regexes. The word-based rules in `FORBIDDEN_RULES` already
330
+ * tokenise, and handing them the normalised string turns a separator that was safely
331
+ * inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
332
+ * unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
333
+ */
334
+ function matchesEitherForm(command, test) {
335
+ return test(command) || test(normalizeCommand(command));
183
336
  }
184
337
  /**
185
338
  * The commands hiding inside a command.
@@ -230,23 +383,57 @@ export function nestedCommands(command) {
230
383
  for (const raw of backticks)
231
384
  found.push(raw.slice(1, -1));
232
385
  }
233
- // `sh -c <command>`. Carries no substitution at all, so the scan above does not
234
- // see it; found while mapping the reported bypass rather than in the report.
235
- const words = splitRespectingQuotes(command);
236
- for (let i = 0; i < words.length; i++) {
237
- if (!SHELL_BINARIES.has(stripPath(unquote(words[i]))))
238
- continue;
239
- for (let j = i + 1; j < words.length; j++) {
240
- if (words[j] === '-c') {
241
- if (words[j + 1] !== undefined)
242
- found.push(words[j + 1]);
243
- break;
386
+ // A carrier hands a program to something that runs it. Carries no substitution, so the
387
+ // scan above does not see it. Read each segment's command word rather than scanning
388
+ // every word: matching a mention rather than an invocation is #91, and
389
+ // `cat /usr/bin/python3` names an interpreter without running one.
390
+ const segments = tokenizeSegmentsDetailed(command);
391
+ for (const { words } of segments) {
392
+ // Every word, not just the command word. Stopping at the head lost the carrier behind
393
+ // any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
394
+ // `flock`, `chroot`, `nsenter` and `systemd-run` are not wrappers it knows — all six
395
+ // went from `privileged` to `safe`. What keeps this from matching a *mention* is not
396
+ // where it looks but what it requires: `programAfterFlag` returns null unless a
397
+ // program-bearing flag actually follows, so `cat /usr/bin/python3` carries nothing.
398
+ //
399
+ // awk is deliberately absent. Its program is a positional operand rather than the
400
+ // value of a flag, so nothing distinguishes running awk from naming it, and its four
401
+ // implementations disagree about which flags consume a value — modelling that wrongly
402
+ // opened five separate holes across two review rounds. `awk` keeps the class it has on
403
+ // 2.5.1 and is tracked separately.
404
+ if (!operandsAreData(words)) {
405
+ for (let i = 0; i < words.length; i++) {
406
+ const spec = INTERPRETERS[stripPath(unquote(words[i]))];
407
+ if (spec === undefined || isFlagValue(words, i, spec.flags))
408
+ continue;
409
+ const program = programAfterFlag(words, i, spec.flags);
410
+ if (program !== null)
411
+ found.push(program);
244
412
  }
245
- // Only flags may sit between the shell and its `-c`; anything else means
246
- // this was not a `-c` invocation.
247
- if (!words[j].startsWith('-'))
248
- break;
249
413
  }
414
+ // `-exec` is a flag rather than a command word, so this one is still a scan.
415
+ for (let i = 0; i < words.length; i++) {
416
+ if (!FIND_EXEC_FLAGS.has(words[i]) || words[i + 1] === undefined)
417
+ continue;
418
+ const rest = words.slice(i + 1);
419
+ const stop = rest.findIndex((w) => w === '+' || w === ';');
420
+ found.push((stop === -1 ? rest : rest.slice(0, stop)).join(' '));
421
+ // Step past what was consumed. Leaving `i` where it was emitted one child per
422
+ // `-exec` token, each an overlapping suffix of the last and each still holding the
423
+ // rest of them, so the recursion re-expanded the same tail once per token: twenty
424
+ // `-ok` tokens in 81 bytes cost 17 seconds of a single-threaded event loop.
425
+ i = stop === -1 ? words.length : i + 1 + stop;
426
+ }
427
+ }
428
+ // A pipe stage whose command word is an interpreter with no program of its own runs
429
+ // whatever the previous stage printed. Starts at 1: a leading `|` records its separator
430
+ // on the first segment, and reading `segments[-1]` threw.
431
+ for (let i = 1; i < segments.length; i++) {
432
+ if (segments[i].sep !== '|')
433
+ continue;
434
+ if (!readsProgramFromStdin(segments[i].words))
435
+ continue;
436
+ found.push(segments[i - 1].words.join(' '));
250
437
  }
251
438
  return found.filter((c) => c.trim().length > 0);
252
439
  }
@@ -255,6 +442,8 @@ const PRIVILEGE_PREFIXES = new Set([
255
442
  // BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
256
443
  // distributions, systemd's replacement, and the Solaris/illumos spelling.
257
444
  'su-exec', 'gosu', 'sudo-rs', 'run0', 'pfexec',
445
+ // Both reach root with the command otherwise classified `safe`.
446
+ 'runuser', 'setpriv',
258
447
  ]);
259
448
  /**
260
449
  * Binaries whose job is to run another command.
@@ -273,6 +462,8 @@ const PRIVILEGE_PREFIXES = new Set([
273
462
  const EXEC_WRAPPERS = new Set([
274
463
  'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
275
464
  'timeout', 'chrt', 'taskset', 'xargs', 'watch',
465
+ // `busybox <applet>` runs the applet, so it wraps rather than interprets.
466
+ 'busybox',
276
467
  ]);
277
468
  /**
278
469
  * Arguments that turn an allowlisted binary into one that writes or executes.
@@ -283,9 +474,8 @@ const EXEC_WRAPPERS = new Set([
283
474
  * \;` form only escaped because `;` happens to be a shell metacharacter, while
284
475
  * the `+` terminator carries none.
285
476
  */
286
- const DISQUALIFYING_ARGS = {
287
- find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
288
- };
477
+ // Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
478
+ const DISQUALIFYING_ARGS = Object.assign(Object.create(null), { find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/ });
289
479
  /** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
290
480
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
291
481
  /** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
@@ -317,8 +507,16 @@ function stripPath(word) {
317
507
  }
318
508
  function parseSegments(command) {
319
509
  const segments = [];
320
- for (const raw of command.split(/[;&|\n]/)) {
321
- const words = raw.trim().split(/\s+/).filter(Boolean);
510
+ for (const words of tokenizeSegments(command)) {
511
+ const segment = parseWords(words);
512
+ if (segment !== null)
513
+ segments.push(segment);
514
+ }
515
+ return segments;
516
+ }
517
+ /** The same, for callers that already hold the tokenised words. */
518
+ function parseWords(words) {
519
+ {
322
520
  let i = 0;
323
521
  while (i < words.length && PRIVILEGE_PREFIXES.has(stripPath(words[i]))) {
324
522
  i++;
@@ -330,10 +528,10 @@ function parseSegments(command) {
330
528
  }
331
529
  }
332
530
  if (i < words.length) {
333
- segments.push({ head: stripPath(words[i]), args: words.slice(i + 1) });
531
+ return { head: stripPath(words[i]), args: words.slice(i + 1) };
334
532
  }
335
533
  }
336
- return segments;
534
+ return null;
337
535
  }
338
536
  /**
339
537
  * Whether a segment asks for elevation, reading past anything that is not yet
@@ -395,8 +593,8 @@ function elevatedBinary(words) {
395
593
  * privileged decision — in the audit log, the OTel span and OPA's input (#134).
396
594
  */
397
595
  function elevatedBinaryOf(command) {
398
- for (const segment of command.split(/[;&|\n]/)) {
399
- const found = elevatedBinary(segment.trim().split(/\s+/).filter(Boolean));
596
+ for (const words of tokenizeSegments(command)) {
597
+ const found = elevatedBinary(words);
400
598
  if (found !== null)
401
599
  return found;
402
600
  }
@@ -441,7 +639,13 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
441
639
  * running one when it succeeds.
442
640
  */
443
641
  function pipesDownloadIntoShell(command) {
444
- const heads = command.split('|').map((part) => parseSegments(part)[0]?.head);
642
+ // One head per pipe stage. Splitting on every separator would make `curl -O x;
643
+ // bash build.sh` — download, then run a local script — match a rule whose label says
644
+ // "piping a download into a shell", on a list that cannot be switched off.
645
+ const segments = tokenizeSegmentsDetailed(command);
646
+ const heads = segments
647
+ .filter((segment, i) => i === 0 || segment.sep === '|')
648
+ .map((segment) => parseWords(segment.words)?.head);
445
649
  const firstDownload = heads.findIndex((h) => h !== undefined && DOWNLOADERS.has(h));
446
650
  if (firstDownload === -1)
447
651
  return false;
@@ -466,7 +670,10 @@ function chownsRoot(command) {
466
670
  });
467
671
  }
468
672
  const FORBIDDEN_RULES = [
469
- ...FORBIDDEN_PATTERNS.map((re) => ({ label: String(re), test: (c) => re.test(c) })),
673
+ ...FORBIDDEN_PATTERNS.map((re) => ({
674
+ label: String(re),
675
+ test: (c) => matchesEitherForm(c, (form) => re.test(form)),
676
+ })),
470
677
  {
471
678
  label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
472
679
  test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
@@ -524,7 +731,8 @@ const DESTRUCTIVE_PATTERNS = [
524
731
  * either.
525
732
  */
526
733
  function isDestructive(command) {
527
- return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
734
+ return (isForbidden(command) ||
735
+ matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
528
736
  }
529
737
  const LEADING_PRIVILEGE_PREFIXES = [
530
738
  /^\s*sudo\b/,
@@ -533,7 +741,12 @@ const LEADING_PRIVILEGE_PREFIXES = [
533
741
  /^\s*pkexec\b/,
534
742
  ];
535
743
  export function extractBinary(command) {
536
- let cmd = command.trim();
744
+ // The first segment's words, so quoting is resolved — `s"u"do id` used to report
745
+ // `s"u"do` in the audit record and the refusal message. Reading the normalised whole
746
+ // command instead would be wrong in the other direction: it rejoins segments with
747
+ // `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
748
+ // the OTel span and OPA's input (#134). A separator is not a binary.
749
+ let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
537
750
  for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
538
751
  cmd = cmd.replace(prefix, '').trim();
539
752
  }
@@ -551,6 +764,16 @@ export function extractBinary(command) {
551
764
  * whether that name is knowable at all.
552
765
  */
553
766
  function effectiveCommandWord(words) {
767
+ const i = effectiveCommandIndex(words);
768
+ return i === -1 ? null : words[i];
769
+ }
770
+ /**
771
+ * The position of that word, for callers that need to read its arguments.
772
+ *
773
+ * Deriving the word from the index rather than searching for it afterwards is what keeps
774
+ * `sh x sh -c 'sudo id'` from finding the wrong `sh`.
775
+ */
776
+ function effectiveCommandIndex(words) {
554
777
  let i = 0;
555
778
  while (i < words.length) {
556
779
  const raw = words[i];
@@ -569,10 +792,167 @@ function effectiveCommandWord(words) {
569
792
  i++;
570
793
  continue;
571
794
  }
572
- return raw;
795
+ return i;
796
+ }
797
+ return -1;
798
+ }
799
+ /**
800
+ * Whether this segment's command word is one whose operands are data.
801
+ *
802
+ * The carrier scan reads every word, because a wrapper this file does not list would
803
+ * otherwise hide `sh -c`. The cost is that an outer tool's own flag can be mistaken for an
804
+ * interpreter's: `grep python3 -c /var/log/x` counts lines, and `-c` is also python's
805
+ * program flag. An allowlisted command is one this file already vouches for as reading
806
+ * rather than running, so its arguments are subjects, not commands — the positive form of
807
+ * the mention-vs-invocation rule at #91. `find` keeps its `-exec` scan, which is separate.
808
+ */
809
+ function operandsAreData(words) {
810
+ const idx = effectiveCommandIndex(words);
811
+ return idx !== -1 && READ_ONLY_ALLOWLIST.has(stripPath(unquote(words[idx])));
812
+ }
813
+ /**
814
+ * Whether an interpreter name is the value of the flag before it rather than a command.
815
+ *
816
+ * The scan looks at every word so that a carrier behind an unlisted wrapper is not lost,
817
+ * and requires a program-bearing flag to follow before it believes one. That is not quite
818
+ * enough: `grep -e perl -e python` puts `perl` after a flag and a second `-e` after it,
819
+ * which reads as perl being handed a program. A search term is not a command.
820
+ *
821
+ * The test is deliberately narrow — the preceding flag must be one of *this interpreter's*
822
+ * program flags. Any flag at all was too much: `nsenter -t 1 -m sh -c 'sudo id'` puts the
823
+ * shell after `-m`, which takes no value, and dropping it lost a real elevation. Nothing
824
+ * here can know an arbitrary tool's grammar, so `sed -n perl -e p` — a shape no one
825
+ * writes — is still read as a carrier.
826
+ */
827
+ function isFlagValue(words, i, flags) {
828
+ if (i === 0)
829
+ return false;
830
+ const previous = words[i - 1];
831
+ return previous.length > 1 && previous.startsWith('-') && flags.includes(previous);
832
+ }
833
+ /**
834
+ * The program an interpreter was handed on its command line, or null.
835
+ *
836
+ * Only flags may sit between the interpreter and its flag; anything else means this was
837
+ * not that kind of invocation, which is what keeps `python3 script.py` — a program this
838
+ * cannot read either, but one every deployment runs — out of the gate.
839
+ */
840
+ function programAfterFlag(words, from, flags) {
841
+ for (let j = from + 1; j < words.length; j++) {
842
+ const word = words[j];
843
+ if (flags.includes(word))
844
+ return words[j + 1] ?? null;
845
+ const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
846
+ if (attached !== undefined) {
847
+ const rest = word.slice(attached.length);
848
+ // `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
849
+ // is a flag cluster and the program is the next word. A space, or the `=` of
850
+ // `--eval=…`, is what tells them apart: a cluster is letters only.
851
+ if (/\s/.test(rest) || rest.startsWith('='))
852
+ return rest.replace(/^=/, '');
853
+ return words[j + 1] ?? null;
854
+ }
855
+ // The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
856
+ // `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
857
+ if (clusterCarriesFlag(word, flags))
858
+ return words[j + 1] ?? null;
859
+ if (!word.startsWith('-'))
860
+ return null;
573
861
  }
574
862
  return null;
575
863
  }
864
+ /**
865
+ * What follows a short flag inside a single-dash cluster, or null if it holds none.
866
+ *
867
+ * Clusters only — a run of single letters after one dash. Long flags and attached values
868
+ * are handled before this is reached.
869
+ */
870
+ function clusterCarriesFlag(word, flags) {
871
+ // Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
872
+ // option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
873
+ // `find . -name perl -type f` read as a carrier and asked for approval.
874
+ if (!/^-[A-Za-z]{2,3}$/.test(word))
875
+ return false;
876
+ const body = word.slice(1);
877
+ // A cluster is letters only, so whatever follows the program flag is more flags — the
878
+ // program itself is always the next word.
879
+ return flags.some((flag) => flag.length === 2 && body.includes(flag[1]));
880
+ }
881
+ /** Spellings of "the program is on standard input" that look like a file operand. */
882
+ const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
883
+ /**
884
+ * Whether a segment's command word is an interpreter with no program of its own.
885
+ *
886
+ * `echo "sudo id" | bash` hands the program over on stdin, where there is nothing to
887
+ * read. This holds for the shells too — `readable` is about a `-c` argument, and there is
888
+ * no `-c` here. Requiring that no operand follow is what keeps `cat data | python3
889
+ * app.py` out of it: there the pipe carries data, not a program.
890
+ */
891
+ function readsProgramFromStdin(words) {
892
+ const idx = effectiveCommandIndex(words);
893
+ if (idx === -1)
894
+ return false;
895
+ const bin = stripPath(words[idx]);
896
+ // Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
897
+ // file flag; every other piped awk reads data, not a program.
898
+ const spec = INTERPRETERS[bin];
899
+ if (spec === undefined)
900
+ return false;
901
+ const flags = spec.flags;
902
+ let sawStdinFlag = false;
903
+ for (let j = idx + 1; j < words.length; j++) {
904
+ const word = words[j];
905
+ if (flags.some((f) => word === f || word.startsWith(f)))
906
+ return false;
907
+ // `--` ends the interpreter's own arguments; what follows is `$1..$n`, so a program
908
+ // still has to come from somewhere and `bash -s -- arg` reads stdin.
909
+ // `-s` says outright that the script comes from stdin, so everything after it is
910
+ // `$1..$n` whatever it looks like.
911
+ if (word === '-s') {
912
+ sawStdinFlag = true;
913
+ continue;
914
+ }
915
+ // `--` ends the interpreter's own arguments. Without `-s` the next word is the script
916
+ // itself, so `bash -- process.sh` reads a file while `bash -s -- arg` reads stdin.
917
+ if (word === '--') {
918
+ const next = words[j + 1];
919
+ return sawStdinFlag || next === undefined || STDIN_PATHS.has(next);
920
+ }
921
+ if (STDIN_PATHS.has(word))
922
+ return true;
923
+ if (!word.startsWith('-'))
924
+ return false;
925
+ }
926
+ return true;
927
+ }
928
+ /**
929
+ * Whether any segment hands a program to something this file cannot read.
930
+ *
931
+ * Keyed on the segment's command word, never on any word that happens to name an
932
+ * interpreter: matching a mention rather than an invocation is the defect this file
933
+ * records fixing as #91, and `cat /usr/bin/python3` names an interpreter without running
934
+ * one. A program-bearing flag must actually be present, so naming one is not enough.
935
+ */
936
+ function hasUnreadableProgram(command) {
937
+ const segments = tokenizeSegmentsDetailed(command);
938
+ for (let i = 0; i < segments.length; i++) {
939
+ const { words, sep } = segments[i];
940
+ if (sep === '|' && readsProgramFromStdin(words))
941
+ return true;
942
+ if (!operandsAreData(words)) {
943
+ for (let j = 0; j < words.length; j++) {
944
+ const spec = INTERPRETERS[stripPath(unquote(words[j]))];
945
+ if (spec === undefined || spec.readable)
946
+ continue;
947
+ if (isFlagValue(words, j, spec.flags))
948
+ continue;
949
+ if (programAfterFlag(words, j, spec.flags) !== null)
950
+ return true;
951
+ }
952
+ }
953
+ }
954
+ return false;
955
+ }
576
956
  /**
577
957
  * Whether any segment runs a command this process cannot name.
578
958
  *
@@ -591,49 +971,79 @@ function effectiveCommandWord(words) {
591
971
  * promoting that would put a prompt on most ordinary shell usage.
592
972
  */
593
973
  function hasUnnameableCommand(command) {
594
- for (const raw of command.split(/[;&|\n]/)) {
595
- const words = raw.trim().split(/\s+/).filter(Boolean);
974
+ for (const words of tokenizeSegments(command)) {
596
975
  const head = effectiveCommandWord(words);
597
976
  if (head !== null && /[$`]/.test(head))
598
977
  return true;
599
978
  }
600
979
  return false;
601
980
  }
981
+ /**
982
+ * Commands this server synthesises rather than a user typing them.
983
+ *
984
+ * These reach the classifier as text like `sftp:upload /etc/passwd`, and no rule named
985
+ * them, so every one fell through to the default `safe` — a class the default rules grant
986
+ * to `operator` on `prod`. Writing an arbitrary file to the target is not `safe`: it is
987
+ * the same authority as `rm`, spelled through a different tool, and it reaches
988
+ * `~/.ssh/authorized_keys` without touching a shell. Opening a session is the same
989
+ * argument, since it hands over an interactive shell.
990
+ *
991
+ * `sftp:download` and `session:close` are deliberately absent. Both would move *down*
992
+ * from `safe` — download to `read-only`, matching its `readOnlyHint`, and close being a
993
+ * release rather than an acquisition. Lowering a class is a widening, and a security
994
+ * release is the wrong place for one; they keep the class they have today.
995
+ */
996
+ const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
997
+ 'sftp:upload': 'destructive',
998
+ 'session:open': 'destructive',
999
+ });
1000
+ /** The first word, tokenised — the synthetic verb when there is one. */
1001
+ function syntheticVerb(command) {
1002
+ return tokenizeSegments(command)[0]?.[0] ?? '';
1003
+ }
1004
+ /**
1005
+ * The class of a command, and of everything it carries.
1006
+ *
1007
+ * A command that carries another decides nothing on its own: the remote shell expands
1008
+ * `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside, so the class is the
1009
+ * higher of the two. The scan for carriers already existed, but it *replaced* the outer
1010
+ * class instead of raising it, and only when the inner class was above `safe` — so
1011
+ * `sudo sh -c 'rm -rf /etc'` reported the inner `destructive` and lost the outer
1012
+ * `privileged`, which on `prod` is the difference between a prompt and a refusal.
1013
+ * Taking the maximum is what makes the scan unable to lower anything.
1014
+ */
602
1015
  export function classifyCommand(command, depth = 0) {
603
1016
  const trimmed = command.trim();
604
- const binary = extractBinary(trimmed);
605
- const fullCommand = trimmed;
606
- // A command that carries another command decides nothing on its own: the remote
607
- // shell expands `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside,
608
- // so the class has to be the higher of the two. Before this, the outer command
609
- // decided alone and `echo $(sudo id)` was `safe` — a class the default rules grant
610
- // on `prod` to `operator` and `admin`, while granting `privileged` there to nobody
611
- // at all (GHSA-v8jh-gv7v-3gvq). It also skipped the approval prompt, since only
612
- // `destructive` and `privileged` raise one, and the audit record said `safe`.
613
- //
614
- // Runs before the checks below rather than after, so the class it produces is not
615
- // something a later branch can lower.
616
- if (depth < MAX_NESTING_DEPTH) {
617
- let highest = null;
618
- for (const inner of nestedCommands(trimmed)) {
619
- const parsed = classifyCommand(inner, depth + 1);
620
- if (highest === null || CLASS_RANK[parsed.class] > CLASS_RANK[highest.class]) {
621
- highest = parsed;
622
- }
623
- }
624
- // `binary` comes from the inner command deliberately: it is what the audit
625
- // record and the refusal message name, and naming the outer `echo` would
626
- // describe the wrong process as the one that ran as root.
627
- if (highest !== null && CLASS_RANK[highest.class] > CLASS_RANK['safe']) {
628
- return { binary: highest.binary, fullCommand, class: highest.class };
629
- }
630
- }
631
- else {
1017
+ const outer = classifyOuter(trimmed);
1018
+ if (depth >= MAX_NESTING_DEPTH) {
632
1019
  // Nesting this deep is not something an operator writes, and we have stopped
633
- // reading. Refusing to guess is the only answer consistent with the rest of
634
- // this function.
635
- return { binary, fullCommand, class: 'privileged' };
1020
+ // reading. Refusing to guess is the only answer consistent with the rest of this
1021
+ // file.
1022
+ return { binary: outer.binary, fullCommand: trimmed, class: 'privileged' };
1023
+ }
1024
+ let highest = outer;
1025
+ // A floor, not a verdict. Returning the synthetic class outright would put it above
1026
+ // the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
1027
+ // record `destructive` where the command is `privileged`.
1028
+ const verb = syntheticVerb(trimmed);
1029
+ const floor = SYNTHETIC_CLASSES[verb];
1030
+ if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
1031
+ highest = { binary: verb, fullCommand: trimmed, class: floor };
1032
+ }
1033
+ for (const inner of nestedCommands(trimmed)) {
1034
+ const parsed = classifyCommand(inner, depth + 1);
1035
+ // `binary` follows the winning side deliberately: it is what the audit record and
1036
+ // the refusal message name, and naming the outer `echo` would describe the wrong
1037
+ // process as the one that ran as root.
1038
+ if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
1039
+ highest = parsed;
636
1040
  }
1041
+ return { binary: highest.binary, fullCommand: trimmed, class: highest.class };
1042
+ }
1043
+ /** The class of the command itself, reading none of what it carries. */
1044
+ function classifyOuter(trimmed) {
1045
+ const binary = extractBinary(trimmed);
1046
+ const fullCommand = trimmed;
637
1047
  // `binary` names the subject of the class. For everything below it is the
638
1048
  // leading command; here it is the one that runs as root, which are the same
639
1049
  // thing only when the prefix leads.
@@ -641,7 +1051,7 @@ export function classifyCommand(command, depth = 0) {
641
1051
  if (elevated !== null) {
642
1052
  return { binary: elevated, fullCommand, class: 'privileged' };
643
1053
  }
644
- if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
1054
+ if (hasUnreadableProgram(trimmed) || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
645
1055
  return { binary, fullCommand, class: 'destructive' };
646
1056
  }
647
1057
  // Below this point every branch assumes the command word names something we
@@ -655,7 +1065,7 @@ export function classifyCommand(command, depth = 0) {
655
1065
  if (hasUnnameableCommand(trimmed)) {
656
1066
  return { binary, fullCommand, class: 'destructive' };
657
1067
  }
658
- const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
1068
+ const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
659
1069
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
660
1070
  if (SHELL_CONTROL_CHARS.test(trimmed)) {
661
1071
  return { binary, fullCommand, class: 'safe' };
@@ -3,7 +3,11 @@ import { OperatorError } from '../errors.js';
3
3
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
4
4
  export const HOST_GROUPS = ['prod', 'staging', 'dev'];
5
5
  export const DEFAULT_RULES = {
6
- roleBindings: {
6
+ // Null-prototype for the reason spelled out at `mergePolicyRules`: a role name is
7
+ // a free string, and this object is indexed by it. `mergePolicyRules` returns this
8
+ // one unchanged when there is no override, so fixing only the merged copy would
9
+ // leave the commonest path — no `[policy]` section at all — on a plain object.
10
+ roleBindings: Object.assign(Object.create(null), {
7
11
  viewer: {
8
12
  prod: ['read-only'],
9
13
  staging: ['read-only'],
@@ -19,7 +23,7 @@ export const DEFAULT_RULES = {
19
23
  staging: ['read-only', 'safe', 'destructive', 'privileged'],
20
24
  dev: ['read-only', 'safe', 'destructive', 'privileged'],
21
25
  },
22
- },
26
+ }),
23
27
  };
24
28
  /**
25
29
  * Layer an operator's `[policy]` section over the compiled-in defaults.
@@ -46,7 +50,25 @@ export function mergePolicyRules(base, override) {
46
50
  return base;
47
51
  // Copy every tier map: assigning base's objects straight through would let a
48
52
  // later merge mutate DEFAULT_RULES, which is a module-level singleton.
49
- const roleBindings = {};
53
+ //
54
+ // `Object.create(null)`, not `{}`, because the loop below assigns a key the
55
+ // operator chose: `roleBindings[role] = ...` with `role === '__proto__'` invokes
56
+ // the prototype setter on a plain object rather than creating a key. Measured, the
57
+ // effect is not global — `Object.prototype` is untouched — but this object's own
58
+ // prototype is replaced by the operator's tier map, after which `roleBindings.prod`
59
+ // resolves through the chain as though `prod` were a role. Reported as
60
+ // https://github.com/tufantunc/ssh-mcp/issues/172.
61
+ //
62
+ // Reaching it needs the schema guard in config/schema.ts to be bypassed first, and
63
+ // that guard is exactly what zod 4 silently disabled once already (2.5.0 rebuilt
64
+ // it). Insurance in the authorization engine is worth one function call.
65
+ //
66
+ // It also removes a second case with no config involved: on a plain object,
67
+ // `roleBindings['toString']` returns a function, so a profile whose role is named
68
+ // after an `Object.prototype` member found a truthy "binding". That failed closed —
69
+ // `findPolicyProblems` still refused it and evaluation floored at read-only — but it
70
+ // took a different branch than an unknown role, for no reason a reader could see.
71
+ const roleBindings = Object.create(null);
50
72
  for (const [role, groups] of Object.entries(base.roleBindings)) {
51
73
  roleBindings[role] = { ...groups };
52
74
  }
@@ -6,8 +6,8 @@ export const TOOL_DESCRIPTIONS = {
6
6
  'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
7
7
  'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
8
8
  'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
9
- 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Destructive commands require user approval.',
10
- 'privileged-command': 'Execute a command with sudo elevation. ALWAYS requires user approval. The sudo password is piped via stdin (never visible in process list).',
9
+ 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal.',
10
+ 'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list).',
11
11
  'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
13
  'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/sdk": "^1.30.0",
31
31
  "@opentelemetry/api": "^1.9.1",
32
- "@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
32
+ "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
33
33
  "@opentelemetry/resources": "^2.9.0",
34
- "@opentelemetry/sdk-node": "^0.220.0",
34
+ "@opentelemetry/sdk-node": "^0.221.0",
35
35
  "@opentelemetry/semantic-conventions": "^1.43.0",
36
36
  "smol-toml": "^1.7.0",
37
37
  "ssh2": "^1.17.0",