ssh-mcp 2.13.0 → 2.14.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.
@@ -41,7 +41,7 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
41
41
  * branch, which only runs when the command words matched a READERS key
42
42
  * exactly — never sees one either.
43
43
  */
44
- function firstUnquotedGlobWord(command, honorQuotes = true) {
44
+ function firstUnquotedGlobWord(command, honorQuotes = true, dialect = 'posix') {
45
45
  let quote = null;
46
46
  let escaped = false;
47
47
  let word = '';
@@ -56,7 +56,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
56
56
  if (quote) {
57
57
  // Backslash is literal inside single quotes; inside double quotes it
58
58
  // escapes — the same distinction the tokeniser makes.
59
- if (ch === '\\' && quote === '"') {
59
+ if (dialect === 'posix' && ch === '\\' && quote === '"') {
60
60
  escaped = true;
61
61
  continue;
62
62
  }
@@ -66,7 +66,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
66
66
  word += ch;
67
67
  continue;
68
68
  }
69
- if (ch === '\\') {
69
+ if (dialect === 'posix' && ch === '\\') {
70
70
  escaped = true;
71
71
  continue;
72
72
  }
@@ -90,7 +90,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
90
90
  // `tokenizeSegmentsDetailed`). Judge it the same way here: in that reading
91
91
  // the glob character is unquoted.
92
92
  if (honorQuotes && quote !== null)
93
- return firstUnquotedGlobWord(command, false);
93
+ return firstUnquotedGlobWord(command, false, dialect);
94
94
  return globInWord ? word : null;
95
95
  }
96
96
  /**
@@ -822,9 +822,11 @@ const MAX_NESTING_DEPTH = 8;
822
822
  * Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
823
823
  * `hasUnnameableCommand` is what refuses a command word this cannot resolve.
824
824
  */
825
- function tokenizeSegments(command) {
826
- return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
825
+ function tokenizeSegments(command, dialect = 'posix') {
826
+ return tokenizeSegmentsDetailed(command, true, dialect).map((segment) => segment.words);
827
827
  }
828
+ /** Inside double quotes, the characters POSIX lets a backslash escape. */
829
+ const DOUBLE_QUOTE_ESCAPABLE = new Set(['$', '`', '"', '\\', '\n']);
828
830
  /**
829
831
  * The same split, keeping the separator that introduced each segment.
830
832
  *
@@ -832,7 +834,7 @@ function tokenizeSegments(command) {
832
834
  * and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
833
835
  * Deriving both views from one tokeniser is what stops the two from drifting apart.
834
836
  */
835
- function tokenizeSegmentsDetailed(command, honorQuotes = true) {
837
+ function tokenizeSegmentsDetailed(command, honorQuotes = true, dialect = 'posix') {
836
838
  const segments = [];
837
839
  let pending = '';
838
840
  let words = [];
@@ -867,7 +869,8 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
867
869
  words = [];
868
870
  pending = sep;
869
871
  };
870
- for (const ch of command) {
872
+ for (let i = 0; i < command.length; i++) {
873
+ const ch = command[i];
871
874
  if (escaped) {
872
875
  // A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
873
876
  // character and dropping the backslash is what the shell does.
@@ -877,8 +880,18 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
877
880
  }
878
881
  if (quote) {
879
882
  // Backslash is literal inside single quotes; inside double quotes it escapes.
880
- if (ch === '\\' && quote === '"') {
881
- escaped = true;
883
+ if (dialect === 'posix' && ch === '\\' && quote === '"') {
884
+ const next = command[i + 1];
885
+ // POSIX keeps `\x` before an ordinary character: only $ ` " \ and a
886
+ // newline are escapable, so the backslash survives into the word
887
+ // (GHSA-972x-g47g-3922's second finding — dropping it misread both
888
+ // dialects at once). At end of string there is no next character to
889
+ // protect, and the open quote sends the whole scan to the fallback.
890
+ if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
891
+ escaped = true;
892
+ continue;
893
+ }
894
+ current += ch;
882
895
  continue;
883
896
  }
884
897
  if (ch === quote)
@@ -887,7 +900,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
887
900
  current += ch;
888
901
  continue;
889
902
  }
890
- if (ch === '\\') {
903
+ if (dialect === 'posix' && ch === '\\') {
891
904
  escaped = true;
892
905
  continue;
893
906
  }
@@ -913,7 +926,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
913
926
  // `privileged` before this tokeniser and `safe` after. So fall back to the scan that
914
927
  // treats the quote character as ordinary text, which splits and still sees `sudo id`.
915
928
  if (quote !== null)
916
- return tokenizeSegmentsDetailed(command, false);
929
+ return tokenizeSegmentsDetailed(command, false, dialect);
917
930
  return segments;
918
931
  }
919
932
  /**
@@ -922,20 +935,26 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
922
935
  * Segments are rejoined with `; ` so a pattern cannot match across two commands that the
923
936
  * shell would run separately.
924
937
  */
925
- let lastNormalizedInput = null;
926
- let lastNormalizedOutput = '';
927
- function normalizeCommand(command) {
938
+ const normalizedCache = new Map();
939
+ function normalizeCommand(command, dialect) {
928
940
  // One evaluate asks for this about nineteen times, once per regex rule, and each ask
929
941
  // re-ran the tokeniser over the whole command. The asks arrive in a row on the same
930
- // input, so a one-entry cache removes almost all of it.
931
- if (command === lastNormalizedInput)
932
- return lastNormalizedOutput;
933
- lastNormalizedInput = command;
934
- lastNormalizedOutput = normalizeUncached(command);
935
- return lastNormalizedOutput;
942
+ // input, so a small cache, evicting the oldest inserted entry, removes almost
943
+ // all of it. The key carries the dialect because both readings of one command
944
+ // are asked about in the same row.
945
+ const key = `${dialect}\u0000${command}`;
946
+ const hit = normalizedCache.get(key);
947
+ if (hit !== undefined)
948
+ return hit;
949
+ const value = normalizeUncached(command, dialect);
950
+ normalizedCache.set(key, value);
951
+ if (normalizedCache.size > 8) {
952
+ normalizedCache.delete(normalizedCache.keys().next().value);
953
+ }
954
+ return value;
936
955
  }
937
- function normalizeUncached(command) {
938
- return tokenizeSegments(command)
956
+ function normalizeUncached(command, dialect) {
957
+ return tokenizeSegments(command, dialect)
939
958
  .map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
940
959
  .join('; ');
941
960
  }
@@ -976,8 +995,8 @@ const PLACEHOLDER = '\u0000';
976
995
  * inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
977
996
  * unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
978
997
  */
979
- function matchesEitherForm(command, test) {
980
- return test(command) || test(normalizeCommand(command));
998
+ function matchesEitherForm(command, test, dialect) {
999
+ return test(command) || test(normalizeCommand(command, dialect));
981
1000
  }
982
1001
  /**
983
1002
  * The commands hiding inside a command.
@@ -1008,7 +1027,7 @@ function matchesEitherForm(command, test) {
1008
1027
  * backticks and `sh -c` are certain carriers and keep reaching the
1009
1028
  * denylist either way.
1010
1029
  */
1011
- export function nestedCommands(command, speculativeOperands = true) {
1030
+ export function nestedCommands(command, speculativeOperands = true, dialect = 'posix') {
1012
1031
  const found = [];
1013
1032
  // `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
1014
1033
  // cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
@@ -1046,7 +1065,7 @@ export function nestedCommands(command, speculativeOperands = true) {
1046
1065
  // scan above does not see it. Read each segment's command word rather than scanning
1047
1066
  // every word: matching a mention rather than an invocation is #91, and
1048
1067
  // `cat /usr/bin/python3` names an interpreter without running one.
1049
- const segments = tokenizeSegmentsDetailed(command);
1068
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1050
1069
  for (const { words } of segments) {
1051
1070
  // Every word, not just the command word. Stopping at the head lost the carrier behind
1052
1071
  // any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
@@ -1277,9 +1296,16 @@ const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
1277
1296
  * `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
1278
1297
  * suppresses alias expansion — but a verbatim string comparison sees three
1279
1298
  * different words. Without this, a one-character edit walks around the check.
1299
+ *
1300
+ * One surrounding quote pair, and nothing else: the scanner has already
1301
+ * resolved escapes by the time a word reaches this, in both the
1302
+ * quote-honouring scan and the `honorQuotes = false` fallback, so a second
1303
+ * pass here could only remove backslashes the dialect meant to keep — the
1304
+ * literal ones inside single quotes, and the POSIX-retained ones inside
1305
+ * double quotes (GHSA-972x-g47g-3922).
1280
1306
  */
1281
1307
  function unquote(word) {
1282
- return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
1308
+ return word.replace(/^(['"])(.*)\1$/, '$2');
1283
1309
  }
1284
1310
  /**
1285
1311
  * Privilege-prefix flags that consume the next argument, so `sudo -u root
@@ -1291,14 +1317,14 @@ const PREFIX_VALUE_FLAGS = new Set([
1291
1317
  '--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
1292
1318
  '--other-user', '--command',
1293
1319
  ]);
1294
- /** `/sbin/reboot` and `reboot` are the same invocation. */
1320
+ /** `/sbin/reboot`, `C:\Windows\reboot.exe` and `reboot` are the same invocation. */
1295
1321
  function stripPath(word) {
1296
- const slash = word.lastIndexOf('/');
1297
- return slash === -1 ? word : word.slice(slash + 1);
1322
+ const cut = Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\'));
1323
+ return cut === -1 ? word : word.slice(cut + 1);
1298
1324
  }
1299
- function parseSegments(command) {
1325
+ function parseSegments(command, dialect = 'posix') {
1300
1326
  const segments = [];
1301
- for (const words of tokenizeSegments(command)) {
1327
+ for (const words of tokenizeSegments(command, dialect)) {
1302
1328
  const segment = parseWords(words);
1303
1329
  if (segment !== null)
1304
1330
  segments.push(segment);
@@ -1383,8 +1409,8 @@ function elevatedBinary(words) {
1383
1409
  * holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
1384
1410
  * privileged decision — in the audit log, the OTel span and OPA's input (#134).
1385
1411
  */
1386
- function elevatedBinaryOf(command) {
1387
- for (const words of tokenizeSegments(command)) {
1412
+ function elevatedBinaryOf(command, dialect = 'posix') {
1413
+ for (const words of tokenizeSegments(command, dialect)) {
1388
1414
  const found = elevatedBinary(words);
1389
1415
  if (found !== null)
1390
1416
  return found;
@@ -1411,8 +1437,8 @@ function elevatedBinaryOf(command) {
1411
1437
  * `invokedWords` also reads it, and widening what counts as "the command"
1412
1438
  * there is a different, larger change this fix does not take on.
1413
1439
  */
1414
- function hasDisqualifyingArgs(command) {
1415
- return tokenizeSegments(command).some((words) => {
1440
+ function hasDisqualifyingArgs(command, dialect = 'posix') {
1441
+ return tokenizeSegments(command, dialect).some((words) => {
1416
1442
  const idx = effectiveCommandIndex(words);
1417
1443
  if (idx === -1)
1418
1444
  return false;
@@ -1420,9 +1446,9 @@ function hasDisqualifyingArgs(command) {
1420
1446
  return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
1421
1447
  });
1422
1448
  }
1423
- function invokedWords(command) {
1449
+ function invokedWords(command, dialect = 'posix') {
1424
1450
  const invoked = [];
1425
- for (const { head, args } of parseSegments(command)) {
1451
+ for (const { head, args } of parseSegments(command, dialect)) {
1426
1452
  invoked.push(head);
1427
1453
  // `systemctl reboot` restarts the host. Reading a unit that happens to be
1428
1454
  // named after a power action is rare enough that erring towards refusal
@@ -1444,11 +1470,11 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
1444
1470
  * runs a shell when the download fails, which is not meaningfully safer than
1445
1471
  * running one when it succeeds.
1446
1472
  */
1447
- function pipesDownloadIntoShell(command) {
1473
+ function pipesDownloadIntoShell(command, dialect = 'posix') {
1448
1474
  // One head per pipe stage. Splitting on every separator would make `curl -O x;
1449
1475
  // bash build.sh` — download, then run a local script — match a rule whose label says
1450
1476
  // "piping a download into a shell", on a list that cannot be switched off.
1451
- const segments = tokenizeSegmentsDetailed(command);
1477
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1452
1478
  const heads = segments
1453
1479
  .filter((segment, i) => i === 0 || segment.sep === '|')
1454
1480
  .map((segment) => parseWords(segment.words)?.head);
@@ -1458,8 +1484,8 @@ function pipesDownloadIntoShell(command) {
1458
1484
  return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
1459
1485
  }
1460
1486
  /** `dd … of=/dev/sda` — writing an image straight onto a block device. */
1461
- function writesToDevice(command) {
1462
- return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1487
+ function writesToDevice(command, dialect = 'posix') {
1488
+ return parseSegments(command, dialect).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1463
1489
  }
1464
1490
  /**
1465
1491
  * `chown -R … /` — a recursive chown whose target is the filesystem root.
@@ -1467,8 +1493,8 @@ function writesToDevice(command) {
1467
1493
  * The last non-flag argument is the target; `chown -R app:app /srv/app` is
1468
1494
  * ordinary and stays allowed.
1469
1495
  */
1470
- function chownsRoot(command) {
1471
- return parseSegments(command).some(({ head, args }) => {
1496
+ function chownsRoot(command, dialect = 'posix') {
1497
+ return parseSegments(command, dialect).some(({ head, args }) => {
1472
1498
  if (head !== 'chown' || !args.includes('-R'))
1473
1499
  return false;
1474
1500
  const positional = args.filter((a) => !a.startsWith('-'));
@@ -1478,18 +1504,18 @@ function chownsRoot(command) {
1478
1504
  const FORBIDDEN_RULES = [
1479
1505
  ...FORBIDDEN_PATTERNS.map((re) => ({
1480
1506
  label: String(re),
1481
- test: (c) => matchesEitherForm(c, (form) => re.test(form)),
1507
+ test: (c, dialect) => matchesEitherForm(c, (form) => re.test(form), dialect),
1482
1508
  })),
1483
1509
  {
1484
1510
  label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
1485
- test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1511
+ test: (command, dialect) => invokedWords(command, dialect).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1486
1512
  },
1487
1513
  {
1488
1514
  label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
1489
- test: pipesDownloadIntoShell,
1515
+ test: (c, dialect) => pipesDownloadIntoShell(c, dialect),
1490
1516
  },
1491
- { label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
1492
- { label: 'a recursive chown of the filesystem root', test: chownsRoot },
1517
+ { label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
1518
+ { label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
1493
1519
  ];
1494
1520
  /**
1495
1521
  * Which never-allowed rule this command trips, or null. The single entry point:
@@ -1498,8 +1524,21 @@ const FORBIDDEN_RULES = [
1498
1524
  * permit `sudo reboot`.
1499
1525
  */
1500
1526
  export function findForbiddenMatch(command, depth = 0) {
1527
+ // The engine calls this directly, so it is a decision point of its own and
1528
+ // reads both dialects like `classifyCommand` does: a refusal under either
1529
+ // reading is a refusal. The destructive-class consultation *inside* a
1530
+ // dialect pass (`isDestructive`) stays in-dialect — that pass is already one
1531
+ // reading of the whole string.
1532
+ if (command.includes('\\')) {
1533
+ const windows = findForbiddenMatchInDialect(command, depth, 'windows');
1534
+ if (windows !== null)
1535
+ return windows;
1536
+ }
1537
+ return findForbiddenMatchInDialect(command, depth, 'posix');
1538
+ }
1539
+ function findForbiddenMatchInDialect(command, depth, dialect) {
1501
1540
  for (const rule of FORBIDDEN_RULES) {
1502
- if (rule.test(command))
1541
+ if (rule.test(command, dialect))
1503
1542
  return rule.label;
1504
1543
  }
1505
1544
  // The same carriers the class scan reads, for the same reason. This list is the
@@ -1523,15 +1562,15 @@ export function findForbiddenMatch(command, depth = 0) {
1523
1562
  // `nestedCommands` regardless of this flag.
1524
1563
  if (depth >= MAX_NESTING_DEPTH)
1525
1564
  return null;
1526
- for (const inner of nestedCommands(command, false)) {
1527
- const match = findForbiddenMatch(inner, depth + 1);
1565
+ for (const inner of nestedCommands(command, false, dialect)) {
1566
+ const match = findForbiddenMatchInDialect(inner, depth + 1, dialect);
1528
1567
  if (match !== null)
1529
1568
  return match;
1530
1569
  }
1531
1570
  return null;
1532
1571
  }
1533
- export function isForbidden(command) {
1534
- return findForbiddenMatch(command) !== null;
1572
+ export function isForbidden(command, dialect = 'posix') {
1573
+ return findForbiddenMatchInDialect(command, 0, dialect) !== null;
1535
1574
  }
1536
1575
  /**
1537
1576
  * Commands that are destructive but legitimate under approval — e.g.
@@ -1548,9 +1587,9 @@ const DESTRUCTIVE_PATTERNS = [
1548
1587
  * reading a log that mentions `reboot` is no longer classified destructive
1549
1588
  * either.
1550
1589
  */
1551
- function isDestructive(command) {
1552
- return (isForbidden(command) ||
1553
- matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
1590
+ function isDestructive(command, dialect = 'posix') {
1591
+ return (isForbidden(command, dialect) ||
1592
+ matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form)), dialect));
1554
1593
  }
1555
1594
  const LEADING_PRIVILEGE_PREFIXES = [
1556
1595
  /^\s*sudo\b/,
@@ -1558,13 +1597,13 @@ const LEADING_PRIVILEGE_PREFIXES = [
1558
1597
  /^\s*doas\b/,
1559
1598
  /^\s*pkexec\b/,
1560
1599
  ];
1561
- export function extractBinary(command) {
1600
+ export function extractBinary(command, dialect = 'posix') {
1562
1601
  // The first segment's words, so quoting is resolved — `s"u"do id` used to report
1563
1602
  // `s"u"do` in the audit record and the refusal message. Reading the normalised whole
1564
1603
  // command instead would be wrong in the other direction: it rejoins segments with
1565
1604
  // `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
1566
1605
  // the OTel span and OPA's input (#134). A separator is not a binary.
1567
- let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
1606
+ let cmd = (tokenizeSegments(command, dialect)[0] ?? []).join(' ').trim();
1568
1607
  for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
1569
1608
  cmd = cmd.replace(prefix, '').trim();
1570
1609
  }
@@ -1910,8 +1949,8 @@ function awkFindings(words) {
1910
1949
  * `awk 'BEGIN{system("sudo id")}'` comes out `privileged` rather than flattened
1911
1950
  * to the `destructive` this function reports.
1912
1951
  */
1913
- function hasDangerousAwk(command) {
1914
- for (const { words } of tokenizeSegmentsDetailed(command)) {
1952
+ function hasDangerousAwk(command, dialect = 'posix') {
1953
+ for (const { words } of tokenizeSegmentsDetailed(command, true, dialect)) {
1915
1954
  const findings = awkFindings(words);
1916
1955
  if (findings === null)
1917
1956
  continue;
@@ -1923,15 +1962,15 @@ function hasDangerousAwk(command) {
1923
1962
  // prints into it, and that is assembled at run time. So it is the same
1924
1963
  // "we cannot tell" this module reports for `-f progfile`.
1925
1964
  for (const target of findings.pipedInto) {
1926
- const stage = tokenizeSegmentsDetailed(target)[0];
1965
+ const stage = tokenizeSegmentsDetailed(target, true, dialect)[0];
1927
1966
  if (stage !== undefined && readsProgramFromStdin(stage.words))
1928
1967
  return true;
1929
1968
  }
1930
1969
  }
1931
1970
  return false;
1932
1971
  }
1933
- function hasUnreadableProgram(command) {
1934
- const segments = tokenizeSegmentsDetailed(command);
1972
+ function hasUnreadableProgram(command, dialect = 'posix') {
1973
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1935
1974
  for (let i = 0; i < segments.length; i++) {
1936
1975
  const { words, sep } = segments[i];
1937
1976
  if (sep === '|' && readsProgramFromStdin(words))
@@ -1971,8 +2010,8 @@ function hasUnreadableProgram(command) {
1971
2010
  * Only the command word, never the arguments. `echo $HOME` names a command we know;
1972
2011
  * promoting that would put a prompt on most ordinary shell usage.
1973
2012
  */
1974
- function hasUnnameableCommand(command) {
1975
- for (const words of tokenizeSegments(command)) {
2013
+ function hasUnnameableCommand(command, dialect = 'posix') {
2014
+ for (const words of tokenizeSegments(command, dialect)) {
1976
2015
  const head = effectiveCommandWord(words);
1977
2016
  if (head !== null && /[$`]/.test(head))
1978
2017
  return true;
@@ -2018,8 +2057,8 @@ const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
2018
2057
  'session:open': 'destructive',
2019
2058
  });
2020
2059
  /** The first word, tokenised — the synthetic verb when there is one. */
2021
- function syntheticVerb(command) {
2022
- return tokenizeSegments(command)[0]?.[0] ?? '';
2060
+ function syntheticVerb(command, dialect = 'posix') {
2061
+ return tokenizeSegments(command, dialect)[0]?.[0] ?? '';
2023
2062
  }
2024
2063
  /**
2025
2064
  * The class of a command, and of everything it carries.
@@ -2033,8 +2072,20 @@ function syntheticVerb(command) {
2033
2072
  * Taking the maximum is what makes the scan unable to lower anything.
2034
2073
  */
2035
2074
  export function classifyCommand(command, depth = 0) {
2075
+ // One string, two dialects, and the class is the worse of the two readings:
2076
+ // the classifier cannot know whether the host shell reads a backslash as an
2077
+ // escape or as a path separator, so policy must hold on whichever host
2078
+ // receives the command (GHSA-972x-g47g-3922). A command without a backslash
2079
+ // is byte-identical under both dialects, so it keeps the single pass.
2080
+ if (!command.includes('\\'))
2081
+ return classifyCommandInDialect(command, depth, 'posix');
2082
+ const posix = classifyCommandInDialect(command, depth, 'posix');
2083
+ const windows = classifyCommandInDialect(command, depth, 'windows');
2084
+ return CLASS_RANK[windows.class] > CLASS_RANK[posix.class] ? windows : posix;
2085
+ }
2086
+ function classifyCommandInDialect(command, depth, dialect) {
2036
2087
  const trimmed = command.trim();
2037
- const outer = classifyOuter(trimmed);
2088
+ const outer = classifyOuter(trimmed, dialect);
2038
2089
  if (depth >= MAX_NESTING_DEPTH) {
2039
2090
  // Nesting this deep is not something an operator writes, and we have stopped
2040
2091
  // reading. Refusing to guess is the only answer consistent with the rest of this
@@ -2045,13 +2096,13 @@ export function classifyCommand(command, depth = 0) {
2045
2096
  // A floor, not a verdict. Returning the synthetic class outright would put it above
2046
2097
  // the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
2047
2098
  // record `destructive` where the command is `privileged`.
2048
- const verb = syntheticVerb(trimmed);
2099
+ const verb = syntheticVerb(trimmed, dialect);
2049
2100
  const floor = SYNTHETIC_CLASSES[verb];
2050
2101
  if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
2051
2102
  highest = { binary: verb, fullCommand: trimmed, class: floor };
2052
2103
  }
2053
- for (const inner of nestedCommands(trimmed)) {
2054
- const parsed = classifyCommand(inner, depth + 1);
2104
+ for (const inner of nestedCommands(trimmed, true, dialect)) {
2105
+ const parsed = classifyCommandInDialect(inner, depth + 1, dialect);
2055
2106
  // `binary` follows the winning side deliberately: it is what the audit record and
2056
2107
  // the refusal message name, and naming the outer `echo` would describe the wrong
2057
2108
  // process as the one that ran as root.
@@ -2073,18 +2124,18 @@ export function classifyCommand(command, depth = 0) {
2073
2124
  return result;
2074
2125
  }
2075
2126
  /** The class of the command itself, reading none of what it carries. */
2076
- function classifyOuter(trimmed) {
2077
- const binary = extractBinary(trimmed);
2127
+ function classifyOuter(trimmed, dialect) {
2128
+ const binary = extractBinary(trimmed, dialect);
2078
2129
  const fullCommand = trimmed;
2079
2130
  // `binary` names the subject of the class. For everything below it is the
2080
2131
  // leading command; here it is the one that runs as root, which are the same
2081
2132
  // thing only when the prefix leads.
2082
- const elevated = elevatedBinaryOf(trimmed);
2133
+ const elevated = elevatedBinaryOf(trimmed, dialect);
2083
2134
  if (elevated !== null) {
2084
2135
  return { binary: elevated, fullCommand, class: 'privileged' };
2085
2136
  }
2086
- if (hasUnreadableProgram(trimmed) || hasDangerousAwk(trimmed)
2087
- || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
2137
+ if (hasUnreadableProgram(trimmed, dialect) || hasDangerousAwk(trimmed, dialect)
2138
+ || isDestructive(trimmed, dialect) || hasDisqualifyingArgs(trimmed, dialect)) {
2088
2139
  return { binary, fullCommand, class: 'destructive' };
2089
2140
  }
2090
2141
  // Below this point every branch assumes the command word names something we
@@ -2095,10 +2146,10 @@ function classifyOuter(trimmed) {
2095
2146
  // `destructive` rather than `privileged`: this is "we cannot tell", not "this is
2096
2147
  // root". It gates on approval instead of refusing outright, which keeps
2097
2148
  // `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
2098
- if (hasUnnameableCommand(trimmed)) {
2149
+ if (hasUnnameableCommand(trimmed, dialect)) {
2099
2150
  return { binary, fullCommand, class: 'destructive' };
2100
2151
  }
2101
- const words = tokenizeSegments(fullCommand)[0] ?? [];
2152
+ const words = tokenizeSegments(fullCommand, dialect)[0] ?? [];
2102
2153
  const twoWordPrefix = words.slice(0, 2).join(' ');
2103
2154
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
2104
2155
  || READ_ONLY_SYNTHETIC.has(binary)) {
@@ -2125,7 +2176,7 @@ function classifyOuter(trimmed) {
2125
2176
  // no option or operand of the binary writes or executes, so extra
2126
2177
  // operands from an expansion are still data.
2127
2178
  if (entry.grammar.args !== 'any') {
2128
- const globWord = firstUnquotedGlobWord(fullCommand);
2179
+ const globWord = firstUnquotedGlobWord(fullCommand, true, dialect);
2129
2180
  if (globWord !== null) {
2130
2181
  return {
2131
2182
  binary,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.13.0",
4
+ "version": "2.14.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",