ssh-mcp 2.11.0 → 2.12.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.
@@ -17,18 +17,100 @@ import { AWK_NAMES, readAwkInvocation } from './awk.js';
17
17
  * write: run-command still accepts it under policy.
18
18
  */
19
19
  const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
20
- const READ_ONLY_ALLOWLIST = new Set([
21
- 'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
22
- 'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
23
- 'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
24
- 'which', 'whereis', 'file', 'readlink', 'realpath', 'basename', 'dirname',
25
- 'seq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm',
26
- 'systemctl status', 'journalctl', 'docker ps', 'docker logs', 'docker inspect',
27
- 'docker stats', 'docker images', 'free', 'top', 'htop', 'iostat', 'vmstat',
28
- 'netstat', 'ss', 'ifconfig', 'ip addr', 'ip route', 'arp', 'dig', 'nslookup',
29
- 'host', 'ping', 'traceroute', 'git status', 'git log',
30
- 'git diff', 'git branch', 'git show', 'git remote',
31
- ]);
20
+ /**
21
+ * The binaries this classifier will vouch for, and what it vouches for about them.
22
+ *
23
+ * Two questions, kept apart because one Set answering both is how #217 turned the
24
+ * interpreter carrier scan off for two verbs by adding them to a list about
25
+ * classes:
26
+ *
27
+ * readOnly does this binary only read? -> decides the class
28
+ * operandsAreData can its operands hide a command? -> decides whether the
29
+ * carrier scan runs
30
+ *
31
+ * Both are `true` for every entry today. The value is not the contents but that
32
+ * the type will not let the next person add a name without answering both.
33
+ *
34
+ * Two mechanisms answer the second question outside this table and are not
35
+ * affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
36
+ * `find` can carry `operandsAreData: true` while `find … -exec sudo id +` is
37
+ * still `privileged`.
38
+ *
39
+ * The two-word entries are looked up only for the class: `operandsAreData` reads
40
+ * a single word, so those rows never reach the second question.
41
+ */
42
+ const READERS = {
43
+ "arp": { readOnly: true, operandsAreData: true },
44
+ "basename": { readOnly: true, operandsAreData: true },
45
+ "cat": { readOnly: true, operandsAreData: true },
46
+ "comm": { readOnly: true, operandsAreData: true },
47
+ "cut": { readOnly: true, operandsAreData: true },
48
+ "date": { readOnly: true, operandsAreData: true },
49
+ "df": { readOnly: true, operandsAreData: true },
50
+ "diff": { readOnly: true, operandsAreData: true },
51
+ "dig": { readOnly: true, operandsAreData: true },
52
+ "dirname": { readOnly: true, operandsAreData: true },
53
+ "docker images": { readOnly: true, operandsAreData: true }, // two-word: class only
54
+ "docker inspect": { readOnly: true, operandsAreData: true }, // two-word: class only
55
+ "docker logs": { readOnly: true, operandsAreData: true }, // two-word: class only
56
+ "docker ps": { readOnly: true, operandsAreData: true }, // two-word: class only
57
+ "docker stats": { readOnly: true, operandsAreData: true }, // two-word: class only
58
+ "du": { readOnly: true, operandsAreData: true },
59
+ "echo": { readOnly: true, operandsAreData: true },
60
+ "false": { readOnly: true, operandsAreData: true },
61
+ "file": { readOnly: true, operandsAreData: true },
62
+ "find": { readOnly: true, operandsAreData: true },
63
+ "free": { readOnly: true, operandsAreData: true },
64
+ "git branch": { readOnly: true, operandsAreData: true }, // two-word: class only
65
+ "git diff": { readOnly: true, operandsAreData: true }, // two-word: class only
66
+ "git log": { readOnly: true, operandsAreData: true }, // two-word: class only
67
+ "git remote": { readOnly: true, operandsAreData: true }, // two-word: class only
68
+ "git show": { readOnly: true, operandsAreData: true }, // two-word: class only
69
+ "git status": { readOnly: true, operandsAreData: true }, // two-word: class only
70
+ "grep": { readOnly: true, operandsAreData: true },
71
+ "head": { readOnly: true, operandsAreData: true },
72
+ "host": { readOnly: true, operandsAreData: true },
73
+ "hostname": { readOnly: true, operandsAreData: true },
74
+ "htop": { readOnly: true, operandsAreData: true },
75
+ "id": { readOnly: true, operandsAreData: true },
76
+ "ifconfig": { readOnly: true, operandsAreData: true },
77
+ "iostat": { readOnly: true, operandsAreData: true },
78
+ "ip addr": { readOnly: true, operandsAreData: true }, // two-word: class only
79
+ "ip route": { readOnly: true, operandsAreData: true }, // two-word: class only
80
+ "journalctl": { readOnly: true, operandsAreData: true },
81
+ "ls": { readOnly: true, operandsAreData: true },
82
+ "netstat": { readOnly: true, operandsAreData: true },
83
+ "nslookup": { readOnly: true, operandsAreData: true },
84
+ "ping": { readOnly: true, operandsAreData: true },
85
+ "printenv": { readOnly: true, operandsAreData: true },
86
+ "printf": { readOnly: true, operandsAreData: true },
87
+ "ps": { readOnly: true, operandsAreData: true },
88
+ "pwd": { readOnly: true, operandsAreData: true },
89
+ "readlink": { readOnly: true, operandsAreData: true },
90
+ "realpath": { readOnly: true, operandsAreData: true },
91
+ "seq": { readOnly: true, operandsAreData: true },
92
+ "sort": { readOnly: true, operandsAreData: true },
93
+ "ss": { readOnly: true, operandsAreData: true },
94
+ "stat": { readOnly: true, operandsAreData: true },
95
+ "systemctl status": { readOnly: true, operandsAreData: true }, // two-word: class only
96
+ "tail": { readOnly: true, operandsAreData: true },
97
+ "test": { readOnly: true, operandsAreData: true },
98
+ "top": { readOnly: true, operandsAreData: true },
99
+ "tr": { readOnly: true, operandsAreData: true },
100
+ "traceroute": { readOnly: true, operandsAreData: true },
101
+ "true": { readOnly: true, operandsAreData: true },
102
+ "uname": { readOnly: true, operandsAreData: true },
103
+ "uniq": { readOnly: true, operandsAreData: true },
104
+ "uptime": { readOnly: true, operandsAreData: true },
105
+ "vmstat": { readOnly: true, operandsAreData: true },
106
+ "wc": { readOnly: true, operandsAreData: true },
107
+ "whereis": { readOnly: true, operandsAreData: true },
108
+ "which": { readOnly: true, operandsAreData: true },
109
+ "who": { readOnly: true, operandsAreData: true },
110
+ "whoami": { readOnly: true, operandsAreData: true },
111
+ };
112
+ /** The class half of READERS, as the shape its consumers already expect. */
113
+ const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
32
114
  // Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
33
115
  // runs <cmd>, so allowlisting the name `env` vouched for a command the
34
116
  // classifier never looked at — `env sudo rm -f /etc/passwd` classified
@@ -177,44 +259,123 @@ const CLASS_RANK = {
177
259
  destructive: 2,
178
260
  privileged: 3,
179
261
  };
262
+ const INTERPRETERS = Object.assign(Object.create(null), {
263
+ sh: { programBearingWords: ['-c'], readable: true },
264
+ bash: { programBearingWords: ['-c'], readable: true },
265
+ dash: { programBearingWords: ['-c'], readable: true },
266
+ zsh: { programBearingWords: ['-c'], readable: true },
267
+ ksh: { programBearingWords: ['-c'], readable: true },
268
+ ash: { programBearingWords: ['-c'], readable: true },
269
+ python: { programBearingWords: ['-c'], readable: false },
270
+ python2: { programBearingWords: ['-c'], readable: false },
271
+ python3: { programBearingWords: ['-c'], readable: false },
272
+ perl: { programBearingWords: ['-e', '-E'], readable: false },
273
+ ruby: { programBearingWords: ['-e'], readable: false },
274
+ // `-p`/`--print` evaluate exactly as `-e` does and then print the result.
275
+ node: { programBearingWords: ['-e', '--eval', '-p', '--print'], readable: false },
276
+ php: { programBearingWords: ['-r'], readable: false },
277
+ // Measured on 2.11.0: each of these classified `safe` while the identical
278
+ // attack through python3 -c classified `destructive` (GHSA-qmx6-47vm-3vf7).
279
+ // `readable: false` throughout, matching python/perl/node: the program is not
280
+ // shell text, so its presence is what counts rather than its content.
281
+ osascript: { programBearingWords: ['-e'], readable: false },
282
+ lua: { programBearingWords: ['-e'], readable: false },
283
+ Rscript: { programBearingWords: ['-e'], readable: false },
284
+ bun: { programBearingWords: ['-e'], readable: false },
285
+ // No program-bearing flag: real tclsh takes a script FILE as its positional
286
+ // argument or reads one from stdin, unlike sh/bash/python/pwsh, none of
287
+ // which have a `-c` of its own. An empty list still marks tclsh as an
288
+ // unreadable interpreter for `readsProgramFromStdin` (`echo … | tclsh`),
289
+ // which is the genuine carrier; it is simply never handed a program inline
290
+ // on its own command line, so there is no flag to name here.
291
+ tclsh: { programBearingWords: [], readable: false },
292
+ // `eval` is a subcommand, not a flag — the field is named for what it holds.
293
+ deno: { programBearingWords: ['eval'], readable: false },
294
+ pwsh: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
295
+ powershell: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
296
+ });
180
297
  /**
181
- * Interpreters, the flags that hand them a program, and whether this file can read it.
298
+ * Every `INTERPRETERS` entry, keyed by its name lower-cased.
182
299
  *
183
- * One table rather than three overlapping name lists: adding an interpreter here is the
184
- * whole change, and a shell list that drifts out of step with a readability list is how a
185
- * new shell ends up treated as an unreadable interpreter.
300
+ * A second index rather than lower-casing the lookup key against `INTERPRETERS`
301
+ * directly, because that table's own keys are not uniformly lower-case —
302
+ * `Rscript` is not `rscript` — so folding only the *input* would still miss it.
303
+ * Built once at module load: the table is fixed, so there is nothing to keep in
304
+ * sync.
305
+ */
306
+ const INTERPRETERS_BY_LOWERCASE_NAME = Object.assign(Object.create(null), Object.fromEntries(Object.entries(INTERPRETERS).map(([name, spec]) => [name.toLowerCase(), spec])));
307
+ /** A Windows executable suffix this table's keys never carry. */
308
+ const WINDOWS_EXE_SUFFIX = /\.(exe|cmd|bat)$/i;
309
+ /**
310
+ * Resolve an interpreter table entry for a command word already stripped of
311
+ * its path (`stripPath(unquote(word))`, as every call site already computes
312
+ * it for the exact-match case below).
186
313
  *
187
- * `readable` means the program is shell, so classifying it is this same job done again
188
- * and the class it earns is real. Python, Perl, Ruby, Node and PHP are not: their program
189
- * is a different language, and `os.system('sudo id')` carries an elevation the shell
190
- * classifier cannot see. Pretending to parse them would be a worse answer than admitting
191
- * we cannot, so an unreadable program makes the command `destructive` — "we cannot tell",
192
- * not "this is root", the same answer `hasUnnameableCommand` gives.
314
+ * The table's keys are exact, case-sensitive, extension-free spellings, so a
315
+ * direct lookup is tried first and is the whole cost for the common case —
316
+ * `sh`, `python3`, the bare lowercase `pwsh`. The fallback strips a trailing
317
+ * `.exe`/`.cmd`/`.bat` and folds case, which is what a Windows target's own
318
+ * shell hands back for the *same* binary: `powershell.exe`, `PWSH.EXE` and
319
+ * `PowerShell` all name the interpreter this table already lists under
320
+ * `powershell`/`pwsh`.
193
321
  *
194
- * `busybox` is not here: it takes an applet name, not `-c`, so `busybox sh -c …` is an
195
- * `sh` invocation behind a wrapper. It is in EXEC_WRAPPERS instead, which is what it is.
322
+ * Applied to every entry, not only pwsh/powershell. The measured bug is
323
+ * about those two, but the gap is not specific to them: a target running
324
+ * `python.exe` or `Node.EXE` carries the identical mismatch, and scoping the
325
+ * fix to two names would leave the rest of the table exactly as blind as it
326
+ * was. This is the binary-*name* question, answered once — it does not touch
327
+ * flag matching, which stays case-sensitive per interpreter (`perl -E` and
328
+ * `perl -e` are still two different flags) via the existing, separate
329
+ * `caseInsensitive` parameter threaded through `programAfterFlag` and
330
+ * friends.
196
331
  */
197
- // Null-prototype for the reason `mergePolicyRules` spells out (#172): this is indexed by
198
- // a command word, which is a free string, so on a plain object `INTERPRETERS['toString']`
199
- // resolves to a function and reading `.flags` off it throws inside the policy gate.
200
- const INTERPRETERS = Object.assign(Object.create(null), {
201
- sh: { flags: ['-c'], readable: true },
202
- bash: { flags: ['-c'], readable: true },
203
- dash: { flags: ['-c'], readable: true },
204
- zsh: { flags: ['-c'], readable: true },
205
- ksh: { flags: ['-c'], readable: true },
206
- ash: { flags: ['-c'], readable: true },
207
- python: { flags: ['-c'], readable: false },
208
- python2: { flags: ['-c'], readable: false },
209
- python3: { flags: ['-c'], readable: false },
210
- perl: { flags: ['-e', '-E'], readable: false },
211
- ruby: { flags: ['-e'], readable: false },
212
- // `-p`/`--print` evaluate exactly as `-e` does and then print the result.
213
- node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
214
- php: { flags: ['-r'], readable: false },
215
- });
332
+ function resolveInterpreter(word) {
333
+ const exact = INTERPRETERS[word];
334
+ if (exact !== undefined)
335
+ return exact;
336
+ return INTERPRETERS_BY_LOWERCASE_NAME[word.replace(WINDOWS_EXE_SUFFIX, '').toLowerCase()];
337
+ }
216
338
  /** `find … -exec <cmd> +` runs cmd. */
217
339
  const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
340
+ /**
341
+ * GNU sort's own long-option table, spelled in full — every entry `sort
342
+ * --help` lists (coreutils 9.11, `docker exec ssh-mcp-ssh-admin-1 sort
343
+ * --help`). Used only to compute which prefixes of `--output` and
344
+ * `--compress-program` `getopt_long` treats as unambiguous.
345
+ *
346
+ * `getopt_long` resolves a `--` word to the option whose name it is an
347
+ * unambiguous prefix of: a prefix shared with another option name is
348
+ * refused outright ("option '--c' is ambiguous; possibilities: '--check'
349
+ * '--compress-program'") rather than run, so it must not be treated as a
350
+ * write. Measured against the binary: `sort --c=x k` exits 2 with no file
351
+ * created; `sort --co=x k` — the second letter is where `--check` and
352
+ * `--compress-program` diverge — runs.
353
+ */
354
+ const SORT_LONG_OPTIONS = [
355
+ 'ignore-leading-blanks', 'dictionary-order', 'ignore-case',
356
+ 'general-numeric-sort', 'ignore-nonprinting', 'month-sort',
357
+ 'human-numeric-sort', 'numeric-sort', 'random-sort', 'random-source',
358
+ 'reverse', 'sort', 'version-sort', 'batch-size', 'check',
359
+ 'compress-program', 'debug', 'files0-from', 'key', 'merge', 'output',
360
+ 'stable', 'buffer-size', 'field-separator', 'temporary-directory',
361
+ 'parallel', 'unique', 'zero-terminated', 'help', 'version',
362
+ ];
363
+ /**
364
+ * The regex-alternation source matching any prefix of `full` that names
365
+ * exactly one entry of `SORT_LONG_OPTIONS` — every prefix `getopt_long`
366
+ * would accept for it, from the shortest unambiguous one up to `full`
367
+ * itself — each optionally followed by `=value`.
368
+ */
369
+ function unambiguousLongOptionSource(full) {
370
+ const prefixes = [];
371
+ for (let len = 1; len <= full.length; len++) {
372
+ const prefix = full.slice(0, len);
373
+ if (SORT_LONG_OPTIONS.filter((opt) => opt.startsWith(prefix)).length === 1) {
374
+ prefixes.push(prefix);
375
+ }
376
+ }
377
+ return `--(?:${prefixes.join('|')})(?:=|$)`;
378
+ }
218
379
  /**
219
380
  * How deep a substitution may nest before we stop reading and refuse to guess.
220
381
  *
@@ -406,8 +567,22 @@ function matchesEitherForm(command, test) {
406
567
  * Four carriers, all of which a remote shell expands and runs:
407
568
  * `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
408
569
  * given `-c`.
570
+ *
571
+ * @param speculativeOperands Whether the catch-all below — any multi-word,
572
+ * non-flag operand of a segment no more specific reader claimed — should be
573
+ * pushed. That catch-all is a guess about a binary this file does not
574
+ * recognise; the four carriers above are not guesses, the shell really does
575
+ * run what they hold, and are pushed regardless of this flag. Defaults to
576
+ * `true` for `classifyCommand`'s own recursion, where a guess may raise a
577
+ * command's *class* and leave role, tier and approval to weigh in.
578
+ * `findForbiddenMatch` passes `false`: its recursion feeds
579
+ * `FORBIDDEN_RULES`, the one unconditional denylist, and a guess must not
580
+ * be able to produce a refusal nobody can override (the maintainer's
581
+ * ruling — see the block comment above `findForbiddenMatch`). `$()`,
582
+ * backticks and `sh -c` are certain carriers and keep reaching the
583
+ * denylist either way.
409
584
  */
410
- export function nestedCommands(command) {
585
+ export function nestedCommands(command, speculativeOperands = true) {
411
586
  const found = [];
412
587
  // `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
413
588
  // cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
@@ -468,13 +643,86 @@ export function nestedCommands(command) {
468
643
  for (const inner of awk?.pipedInto ?? [])
469
644
  found.push(inner);
470
645
  if (!operandsAreData(words)) {
646
+ // Which operand *indices* an interpreter actually consumed as a program —
647
+ // not whether the segment's head happens to be a name in the table, and not
648
+ // a single segment-wide flag either. A set of indices rather than a boolean:
649
+ // `unknownbin sh -c true 'sudo id'` has `sh -c` consume `true` (harmless,
650
+ // pushed below) and leave `'sudo id'` untouched, and a boolean here defused
651
+ // the catch-all for that second operand too — measured, it classified
652
+ // `safe`. Only the specific word an interpreter actually read should be
653
+ // excluded from the catch-all; every other operand in the segment is still
654
+ // this binary's own, unclaimed by anything more specific.
655
+ //
656
+ // A binary being recognised is also not the same as this segment's
657
+ // invocation of it being one the loop could parse:
658
+ // `pwsh -ExecutionPolicy Bypass -Command 'sudo id'` has a recognised head
659
+ // and a program on the line, but `-ExecutionPolicy` takes a value
660
+ // (`Bypass`) that isn't itself a flag, and `programAfterFlag` gives up
661
+ // rather than guess past it. Gating the catch-all below on "head is in the
662
+ // table" excluded exactly the binaries this file just learned, and for
663
+ // precisely the invocations its own flag-walk cannot follow — a net
664
+ // regression, not a wash.
665
+ const consumedOperands = new Set();
471
666
  for (let i = 0; i < words.length; i++) {
472
- const spec = INTERPRETERS[stripPath(unquote(words[i]))];
473
- if (spec === undefined || isFlagValue(words, i, spec.flags))
667
+ const spec = resolveInterpreter(stripPath(unquote(words[i])));
668
+ // pwsh/powershell's own parameter binder resolves `-ENC`/`-Enc`/`-enc` to the
669
+ // same parameter; every other interpreter's flags are exact letters (`-E` and
670
+ // `-e` are different flags to perl). `-EncodedCommand` only ever appears on
671
+ // the two entries whose own parameter binder works this way, so its presence
672
+ // is the signal for which interpreter this is, not a hardcoded name check.
673
+ const caseInsensitive = spec?.programBearingWords.includes('-EncodedCommand') ?? false;
674
+ if (spec === undefined || isFlagValue(words, i, spec.programBearingWords, caseInsensitive))
474
675
  continue;
475
- const program = programAfterFlag(words, i, spec.flags);
476
- if (program !== null)
477
- found.push(program);
676
+ // Same signal, second job: pwsh/powershell's argument parser also does not stop
677
+ // at an unrecognised option's bare value (`-ExecutionPolicy Bypass`) the way a
678
+ // POSIX interpreter stops at its first positional argument. See
679
+ // `programAfterFlag`'s `tolerateUnknownWordsAtHead` for why this is scoped to
680
+ // `i === 0` there rather than here.
681
+ const result = programAfterFlag(words, i, spec.programBearingWords, caseInsensitive, caseInsensitive);
682
+ if (result !== null) {
683
+ consumedOperands.add(result.index);
684
+ found.push(result.program);
685
+ // An encoded program is opaque to every text scan until it is decoded. Gated on
686
+ // the word `programAfterFlag` actually matched — the table's own canonical
687
+ // spelling, so every case-folded or clustered form of `-EncodedCommand`/`-e`
688
+ // reaches this the same way a literal `-EncodedCommand` does — not a scan of
689
+ // the whole segment for one long spelling.
690
+ if (caseInsensitive && (result.flag === '-EncodedCommand' || result.flag === '-e')) {
691
+ const decoded = decodedPowerShellCommand(result.program);
692
+ if (decoded !== null)
693
+ found.push(decoded);
694
+ }
695
+ }
696
+ }
697
+ // An operand of a binary nothing more specific has read is classified as a
698
+ // command in its own right, rather than scanned as text.
699
+ //
700
+ // The gate is the awk reader's own result, and now which specific operands
701
+ // an interpreter actually consumed, rather than a list of names: a name
702
+ // list here would be the defect this change exists to fix
703
+ // (GHSA-qmx6-47vm-3vf7), and "the head is a name in the table" turned out
704
+ // to be one too — it answers a different question than "did this
705
+ // invocation's program-bearing word actually resolve". `awk` is already
706
+ // null for every non-awk segment.
707
+ //
708
+ // Whitespace is what separates an operand worth classifying from one that is
709
+ // not: a single token is a path, a flag value or a subcommand, while a
710
+ // multi-word operand has the shape of a command. Flags are skipped.
711
+ //
712
+ // Deliberately NOT a text scan for `sudo`. That version asserted elevations
713
+ // the awk reader and the variable-command-word logic refuse to assert — both
714
+ // cap at `destructive` on purpose — and it out-ranked the nested
715
+ // classification that names the elevated binary, reporting `awk` where `id`
716
+ // was correct.
717
+ if (speculativeOperands && awk === null) {
718
+ for (let i = 1; i < words.length; i++) {
719
+ if (consumedOperands.has(i))
720
+ continue;
721
+ if (words[i].startsWith('-'))
722
+ continue;
723
+ if (/\s/.test(words[i]))
724
+ found.push(words[i]);
725
+ }
478
726
  }
479
727
  }
480
728
  // `-exec` is a flag rather than a command word, so this one is still a scan.
@@ -541,7 +789,58 @@ const EXEC_WRAPPERS = new Set([
541
789
  * the `+` terminator carries none.
542
790
  */
543
791
  // Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
544
- const DISQUALIFYING_ARGS = Object.assign(Object.create(null), { find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/ });
792
+ const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
793
+ find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
794
+ // GNU sort execs this for every temporary file it spills, so a reader
795
+ // becomes a launcher. Measured against coreutils 9.11: an attacker-named
796
+ // script ran 14,224 times for one 200k-line input, and the whole command
797
+ // classified `read-only` — which a `readOnly` viewer is allowed to run,
798
+ // while running that same program directly is denied.
799
+ //
800
+ // `-o FILE` / `--output=FILE` is the same shape of bug with a plainer
801
+ // payoff: it creates and truncates FILE, which is a write a `readOnly`
802
+ // profile must never reach through a command classified `read-only`.
803
+ // Measured on HEAD before this rule: `sort -o /root/.ssh/authorized_keys
804
+ // /tmp/key.pub` classified `read-only`.
805
+ //
806
+ // Targeted round (2026-09-24, R2+R3): the version above closed the exact
807
+ // spellings `-o`, `--output` and `--compress-program` and left the CLASS
808
+ // open — GNU sort's own option grammar has two escapes past a fixed
809
+ // spelling, both measured against the real binary:
810
+ //
811
+ // - A short-option cluster is scanned left to right, and any of sort's
812
+ // own *argument-less* short flags may sit ahead of `-o` without
813
+ // consuming it: `sort -mo out in` writes `out` exactly as `sort -o out
814
+ // in` does. `-m` (merge) is itself argument-less, and its absence from
815
+ // the char class below is exactly what let `sort -mo …` through as
816
+ // `read-only`.
817
+ // - `getopt_long` resolves a `--` word by unambiguous-prefix matching:
818
+ // `--o`, `--ou`, `--out`, `--outp`, `--outpu` all mean `--output`
819
+ // (nothing else starts with `o`), and `--co` through
820
+ // `--compress-progra` all mean `--compress-program` (`--c` alone is
821
+ // ambiguous with `--check` and sort refuses to run at all —
822
+ // `unambiguousLongOptionSource` excludes it for exactly that reason,
823
+ // computed from sort's own option table rather than hand-picked).
824
+ //
825
+ // The cluster branch is still deliberately narrower than "any `o` in a
826
+ // dash word": `-[bcCdfghiMmnRrsuVz]*o` only allows GNU sort's own
827
+ // argument-less short flags ahead of the `o`, so it stops at the first
828
+ // flag that takes a value of its own. Without that, `-tofile` — `-t`
829
+ // (field separator) with its value attached, not `-o` — would be
830
+ // misread as a write. `-t`, `-k`, `-S` and `-T` are exactly the short
831
+ // flags this excludes, because each consumes the rest of a clustered
832
+ // word as its own argument, so a following `o` is that argument's text,
833
+ // not `-o` invoked.
834
+ //
835
+ // `find`'s entry above does not get the same prefix-abbreviation
836
+ // treatment: `find` parses its predicates itself rather than through
837
+ // `getopt_long`, and accepts no abbreviated spelling of `-exec` — `find .
838
+ // -exe` is "unknown predicate `-exe`", measured. There is no class to
839
+ // close there.
840
+ sort: new RegExp(`^(?:${unambiguousLongOptionSource('compress-program')}`
841
+ + `|${unambiguousLongOptionSource('output')}`
842
+ + '|-[bcCdfghiMmnRrsuVz]*o)'),
843
+ });
545
844
  /** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
546
845
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
547
846
  /** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
@@ -673,11 +972,26 @@ function elevatedBinaryOf(command) {
673
972
  * behind a wrapper, behind an assignment, or after a separator all reached root
674
973
  * with the command classified `safe` or `read-only`.
675
974
  */
676
- /** An allowlisted binary carrying a flag that makes it write or execute. */
975
+ /**
976
+ * An allowlisted binary carrying a flag that makes it write or execute.
977
+ *
978
+ * Keyed on `effectiveCommandIndex`, not `parseSegments`. `parseSegments`
979
+ * (`parseWords`) only steps over a privilege prefix (`sudo`, `su`, …), not an
980
+ * exec wrapper (`env`, `nohup`, `timeout`, …), so `env sort
981
+ * --compress-program=X` read `head` as `"env"` — not in `DISQUALIFYING_ARGS`
982
+ * — and the flag went unnoticed. `effectiveCommandIndex` already reads past
983
+ * both, which is what `operandsAreData` and `readsProgramFromStdin` use it
984
+ * for. Deliberately not fixed by changing `parseWords` itself:
985
+ * `invokedWords` also reads it, and widening what counts as "the command"
986
+ * there is a different, larger change this fix does not take on.
987
+ */
677
988
  function hasDisqualifyingArgs(command) {
678
- return parseSegments(command).some(({ head, args }) => {
679
- const rule = DISQUALIFYING_ARGS[head];
680
- return rule !== undefined && args.some((arg) => rule.test(arg));
989
+ return tokenizeSegments(command).some((words) => {
990
+ const idx = effectiveCommandIndex(words);
991
+ if (idx === -1)
992
+ return false;
993
+ const rule = DISQUALIFYING_ARGS[stripPath(words[idx])];
994
+ return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
681
995
  });
682
996
  }
683
997
  function invokedWords(command) {
@@ -769,9 +1083,21 @@ export function findForbiddenMatch(command, depth = 0) {
769
1083
  // They still classified `destructive`, which on the `prod` tier degrades an
770
1084
  // absolute `deny` into `require-approval`: a rule that answers "never" became
771
1085
  // one a human can click through.
1086
+ //
1087
+ // `speculativeOperands: false` — the maintainer's ruling. `nestedCommands`'
1088
+ // catch-all is a guess about an unrecognised binary's operands, and this loop
1089
+ // feeds the one unconditional rule in the policy: a guess may raise a
1090
+ // command's class, which role, tier and approval still get to weigh in on,
1091
+ // but it must not manufacture a refusal nobody can override. Measured before
1092
+ // this parameter existed: `git commit -m 'reboot the worker pool'` was a hard
1093
+ // deny on an admin profile with `approvalPolicy: 'auto'`, because the catch-all
1094
+ // read the quoted commit message as an operand starting with a forbidden word.
1095
+ // `$()`, backticks and `sh -c` are certain carriers, not guesses — the shell
1096
+ // really does run what they hold — and are unaffected: they are pushed by
1097
+ // `nestedCommands` regardless of this flag.
772
1098
  if (depth >= MAX_NESTING_DEPTH)
773
1099
  return null;
774
- for (const inner of nestedCommands(command)) {
1100
+ for (const inner of nestedCommands(command, false)) {
775
1101
  const match = findForbiddenMatch(inner, depth + 1);
776
1102
  if (match !== null)
777
1103
  return match;
@@ -874,7 +1200,9 @@ function effectiveCommandIndex(words) {
874
1200
  */
875
1201
  function operandsAreData(words) {
876
1202
  const idx = effectiveCommandIndex(words);
877
- return idx !== -1 && READ_ONLY_ALLOWLIST.has(stripPath(unquote(words[idx])));
1203
+ if (idx === -1)
1204
+ return false;
1205
+ return READERS[stripPath(unquote(words[idx]))]?.operandsAreData === true;
878
1206
  }
879
1207
  /**
880
1208
  * Whether an interpreter name is the value of the flag before it rather than a command.
@@ -904,59 +1232,169 @@ function operandsAreData(words) {
904
1232
  function skippableBetweenFlags(word) {
905
1233
  return word === '' || word.startsWith('-');
906
1234
  }
907
- function isFlagValue(words, i, flags) {
1235
+ /**
1236
+ * Whether `word` is a spelling of `flag`, honouring case-insensitivity when asked.
1237
+ *
1238
+ * Scoped per call, never globally: pwsh/powershell's own parameter binder resolves
1239
+ * `-ENC`, `-Enc` and `-enc` to the same parameter, but `-E` and `-e` are two different
1240
+ * flags to perl. Every caller here is told explicitly, per invocation, whether the
1241
+ * interpreter it is matching against is one of the case-insensitive ones — folding case
1242
+ * is never the default.
1243
+ */
1244
+ function sameFlag(word, flag, caseInsensitive) {
1245
+ return caseInsensitive ? word.toLowerCase() === flag.toLowerCase() : word === flag;
1246
+ }
1247
+ function isFlagValue(words, i, flags, caseInsensitive = false) {
908
1248
  if (i === 0)
909
1249
  return false;
910
1250
  const previous = words[i - 1];
911
- return previous.length > 1 && previous.startsWith('-') && flags.includes(previous);
1251
+ if (!(previous.length > 1 && previous.startsWith('-')))
1252
+ return false;
1253
+ return flags.some((f) => sameFlag(previous, f, caseInsensitive));
912
1254
  }
913
1255
  /**
914
1256
  * The program an interpreter was handed on its command line, or null.
915
1257
  *
916
1258
  * Only flags may sit between the interpreter and its flag; anything else means this was
917
1259
  * not that kind of invocation, which is what keeps `python3 script.py` — a program this
918
- * cannot read either, but one every deployment runs — out of the gate.
1260
+ * cannot read either, but one every deployment runs — out of the gate. That rule holds
1261
+ * for every interpreter here except pwsh/powershell (see `tolerateUnknownWordsAtHead`):
1262
+ * real POSIX interpreters stop parsing their own options at the first positional
1263
+ * argument — `python3 script.py -c 'evil'` hands `-c evil` to the script as `argv`, not
1264
+ * to python — so treating a bare word as "not this kind of invocation" is correct for
1265
+ * them, not merely convenient.
1266
+ *
1267
+ * Returns which of `flags` matched alongside the program, not just the program text, so
1268
+ * a caller can tell `-EncodedCommand` from its `-e` abbreviation apart from every other
1269
+ * program-bearing word — decoding is specific to that one flag, and both spellings reach
1270
+ * here as an ordinary match.
1271
+ *
1272
+ * @param tolerateUnknownWordsAtHead pwsh/powershell's own argument parser walks the
1273
+ * whole command line looking for named parameters it recognises and does not stop at
1274
+ * an unrecognised one's value (`-ExecutionPolicy Bypass`) the way a POSIX interpreter
1275
+ * stops at its first positional argument. Only takes effect when `from` is the
1276
+ * segment's *effective* command word — `effectiveCommandIndex(words)`, which reads
1277
+ * past a privilege prefix or an exec wrapper (`env`, `nohup`, `timeout`, …), not merely
1278
+ * position 0. `from === 0` missed exactly the shape those wrappers exist to describe:
1279
+ * `env pwsh -ExecutionPolicy Bypass -EncodedCommand …` put `pwsh` at index 1, the
1280
+ * tolerance never engaged, and the interpreter loop's own flag-walk gave up at
1281
+ * `Bypass` before ever reaching `-EncodedCommand` — classified `safe`. Still unambiguous
1282
+ * about *which* word is being invoked, not a value or a search term this file has no
1283
+ * business reinterpreting (`grep -e perl -e python` never reaches here with this set,
1284
+ * because `perl` is not the segment's effective command word and isn't pwsh-family
1285
+ * regardless; nor is `customtool --search pwsh …`, where `customtool` is). Structural,
1286
+ * not a list of pwsh's value-taking flags: this file does not need to know
1287
+ * `-ExecutionPolicy` exists to stop being confused by it.
919
1288
  */
920
- function programAfterFlag(words, from, flags) {
1289
+ function programAfterFlag(words, from, flags, caseInsensitive = false, tolerateUnknownWordsAtHead = false) {
1290
+ const tolerateUnknownWords = tolerateUnknownWordsAtHead && from === effectiveCommandIndex(words);
921
1291
  for (let j = from + 1; j < words.length; j++) {
922
1292
  const word = words[j];
923
- if (flags.includes(word))
924
- return words[j + 1] ?? null;
1293
+ const exact = flags.find((f) => sameFlag(word, f, caseInsensitive));
1294
+ if (exact !== undefined) {
1295
+ // A subcommand — a program-bearing word that is not itself a `-` flag, such as
1296
+ // `deno`'s `eval` — may still have its own options before the code: `deno eval
1297
+ // --unstable <code>` is one invocation, not two. A real flag like `-c` or
1298
+ // `-Command` never has anything of its own between it and the program, so this
1299
+ // only widens the subcommand case.
1300
+ let k = j + 1;
1301
+ if (!exact.startsWith('-')) {
1302
+ while (k < words.length && skippableBetweenFlags(words[k]))
1303
+ k++;
1304
+ }
1305
+ const program = words[k];
1306
+ return program === undefined ? null : { program, flag: exact, index: k };
1307
+ }
1308
+ // Deliberately case-SENSITIVE even for pwsh/powershell: this branch exists for a
1309
+ // value glued directly onto a short flag with no separating space (`-csudo id`).
1310
+ // Folding case here as well made `-ExecutionPolicy` — a real pwsh option this file
1311
+ // does not otherwise track — case-insensitively start with `-e` and get read as
1312
+ // `-e` plus an attached `xecutionPolicy`, which swallowed `-ExecutionPolicy Bypass
1313
+ // -Command 'sudo id'` into `destructive` and, worse, made `foundProgram` true so
1314
+ // the catch-all below never ran. Every case-insensitive spelling the table needs
1315
+ // to accept (`-enc`, `-ec`, `-EC`, `-ENC`, `-E`, `-EncodedCOMMAND`, `-COMMAND`,
1316
+ // `-C`, …) is already reached through the exact match above or the cluster match
1317
+ // below, so this branch does not need to fold case to cover them.
925
1318
  const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
926
1319
  if (attached !== undefined) {
927
1320
  const rest = word.slice(attached.length);
928
1321
  // `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
929
1322
  // is a flag cluster and the program is the next word. A space, or the `=` of
930
1323
  // `--eval=…`, is what tells them apart: a cluster is letters only.
931
- if (/\s/.test(rest) || rest.startsWith('='))
932
- return rest.replace(/^=/, '');
933
- return words[j + 1] ?? null;
1324
+ if (/\s/.test(rest) || rest.startsWith('=')) {
1325
+ return { program: rest.replace(/^=/, ''), flag: attached, index: j };
1326
+ }
1327
+ const program = words[j + 1];
1328
+ return program === undefined ? null : { program, flag: attached, index: j + 1 };
934
1329
  }
935
1330
  // The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
936
1331
  // `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
937
- if (clusterCarriesFlag(word, flags))
938
- return words[j + 1] ?? null;
939
- if (!skippableBetweenFlags(word))
1332
+ const clusterFlag = clusterCarriesFlag(word, flags, caseInsensitive);
1333
+ if (clusterFlag !== null) {
1334
+ const program = words[j + 1];
1335
+ return program === undefined ? null : { program, flag: clusterFlag, index: j + 1 };
1336
+ }
1337
+ if (!skippableBetweenFlags(word) && !tolerateUnknownWords)
940
1338
  return null;
941
1339
  }
942
1340
  return null;
943
1341
  }
1342
+ /** How much base64 is worth decoding before the answer stops changing. */
1343
+ const MAX_ENCODED_CHARS = 64 * 1024;
1344
+ /**
1345
+ * The command inside `-EncodedCommand`, or null.
1346
+ *
1347
+ * PowerShell encodes UTF-16LE, so decoding as utf8 yields text with a NUL between
1348
+ * every character and no pattern matches it. `Buffer.from(x, 'base64')` never
1349
+ * throws — it drops characters outside the alphabet — so malformed input produces
1350
+ * a wrong answer rather than an exception, and the caller must treat null and
1351
+ * nonsense alike: the flag alone has already made the command `destructive`.
1352
+ *
1353
+ * Reads a bounded, 4-aligned PREFIX rather than refusing outright past the limit. A
1354
+ * refusal is a downgrade path: it turns "pad the payload past 64 KiB" into a way to
1355
+ * trade `privileged` for `destructive`, which is strictly better for whoever is padding
1356
+ * it. 4-aligned because base64 decodes in groups of four characters to three bytes;
1357
+ * truncating mid-group corrupts the last partial character instead of just dropping
1358
+ * trailing content the decode was never going to reach anyway.
1359
+ */
1360
+ function decodedPowerShellCommand(operand) {
1361
+ const prefix = operand.length > MAX_ENCODED_CHARS
1362
+ ? operand.slice(0, MAX_ENCODED_CHARS - (MAX_ENCODED_CHARS % 4))
1363
+ : operand;
1364
+ const decoded = Buffer.from(prefix, 'base64').toString('utf16le');
1365
+ return decoded.includes('�') || decoded.trim() === '' ? null : decoded;
1366
+ }
944
1367
  /**
945
- * What follows a short flag inside a single-dash cluster, or null if it holds none.
1368
+ * What follows a short flag inside a single-dash cluster, or the `flags` entry it
1369
+ * carries, or null if it carries none.
946
1370
  *
947
1371
  * Clusters only — a run of single letters after one dash. Long flags and attached values
948
- * are handled before this is reached.
1372
+ * are handled before this is reached. Returns the canonical flag rather than a boolean
1373
+ * for the same reason `programAfterFlag` does: a caller deciding whether to decode
1374
+ * compares against the table's own spellings, not against whatever letters happened to
1375
+ * be clustered together.
949
1376
  */
950
- function clusterCarriesFlag(word, flags) {
1377
+ function clusterCarriesFlag(word, flags, caseInsensitive = false) {
951
1378
  // Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
952
1379
  // option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
953
1380
  // `find . -name perl -type f` read as a carrier and asked for approval.
954
1381
  if (!/^-[A-Za-z]{2,3}$/.test(word))
955
- return false;
956
- const body = word.slice(1);
1382
+ return null;
1383
+ const body = caseInsensitive ? word.slice(1).toLowerCase() : word.slice(1);
1384
+ const shortFlags = flags.filter((flag) => flag.length === 2);
957
1385
  // A cluster is letters only, so whatever follows the program flag is more flags — the
958
1386
  // program itself is always the next word.
959
- return flags.some((flag) => flag.length === 2 && body.includes(flag[1]));
1387
+ //
1388
+ // Scanned by the BODY's own character order, not the table's order: `-ec` must
1389
+ // resolve to `-e` (so a caller deciding whether to decode sees `-e`), not to `-c`
1390
+ // just because `-c` happens to sit first in pwsh's `programBearingWords`. The first
1391
+ // letter in the cluster is the one the invocation leads with.
1392
+ for (const letter of body) {
1393
+ const match = shortFlags.find((flag) => (caseInsensitive ? flag[1].toLowerCase() : flag[1]) === letter);
1394
+ if (match !== undefined)
1395
+ return match;
1396
+ }
1397
+ return null;
960
1398
  }
961
1399
  /** Spellings of "the program is on standard input" that look like a file operand. */
962
1400
  const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
@@ -975,10 +1413,10 @@ function readsProgramFromStdin(words) {
975
1413
  const bin = stripPath(words[idx]);
976
1414
  // Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
977
1415
  // file flag; every other piped awk reads data, not a program.
978
- const spec = INTERPRETERS[bin];
1416
+ const spec = resolveInterpreter(bin);
979
1417
  if (spec === undefined)
980
1418
  return false;
981
- const flags = spec.flags;
1419
+ const flags = spec.programBearingWords;
982
1420
  let sawStdinFlag = false;
983
1421
  for (let j = idx + 1; j < words.length; j++) {
984
1422
  const word = words[j];
@@ -1074,12 +1512,16 @@ function hasUnreadableProgram(command) {
1074
1512
  return true;
1075
1513
  if (!operandsAreData(words)) {
1076
1514
  for (let j = 0; j < words.length; j++) {
1077
- const spec = INTERPRETERS[stripPath(unquote(words[j]))];
1515
+ const spec = resolveInterpreter(stripPath(unquote(words[j])));
1078
1516
  if (spec === undefined || spec.readable)
1079
1517
  continue;
1080
- if (isFlagValue(words, j, spec.flags))
1518
+ // Same case-insensitivity as `nestedCommands`'s interpreter loop, and for the
1519
+ // same reason: pwsh/powershell's own parameter binder does not distinguish
1520
+ // `-EC` from `-ec`, so neither should this.
1521
+ const caseInsensitive = spec.programBearingWords.includes('-EncodedCommand');
1522
+ if (isFlagValue(words, j, spec.programBearingWords, caseInsensitive))
1081
1523
  continue;
1082
- if (programAfterFlag(words, j, spec.flags) !== null)
1524
+ if (programAfterFlag(words, j, spec.programBearingWords, caseInsensitive, caseInsensitive) !== null)
1083
1525
  return true;
1084
1526
  }
1085
1527
  }
@@ -1228,4 +1670,4 @@ function classifyOuter(trimmed) {
1228
1670
  }
1229
1671
  return { binary, fullCommand, class: 'safe' };
1230
1672
  }
1231
- export { READ_ONLY_ALLOWLIST, READ_ONLY_SYNTHETIC, isDestructive };
1673
+ export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.11.0",
4
+ "version": "2.12.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",