ssh-mcp 2.18.0 → 2.19.1

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
@@ -593,11 +593,29 @@ read-only only with the options and operands its grammar lists; `--foo` is not a
593
593
  there. ``), so the caller can see which spelling of the same command still works. See
594
594
  [SECURITY.md](./SECURITY.md) for the residual risks this leaves open.
595
595
 
596
+ A reader also falls to `safe` when its command carries a shell metacharacter — `;` `&` `|`
597
+ `<` `>` `` ` `` `$` `(` `)` `{` `}`, a newline or a carriage return — quoted or not, because
598
+ the allowlist vouches for the binary, never for what a shell might do with the rest of the
599
+ line. The refusal names the first such character. For `sftp-list` and `sftp-download` it is
600
+ in the remote path, so `/srv/backup(old)` is refused for a `readOnly` profile with
601
+ `` The remote path contains `(`, which counts as a shell metacharacter here, so `sftp:list`
602
+ is read-only only for a path without one. ``
603
+
596
604
  A separate **forbidden** list is never allowed, whatever the role or approval
597
605
  policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
598
- writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and
599
- recursive `chmod 777 /` / `chown /`. Add your own patterns via the policy
600
- denylist; an invalid pattern fails at startup rather than degrading silently.
606
+ writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
607
+ `iptables -F`, and recursive `chmod 777 /` / `chown /`. A write *names* one of
608
+ those paths when it is the remote path of an upload tool, the target of a shell
609
+ redirect, or a destination of `tee`, `cp`, `mv`, `install` or `dd of=` — in any
610
+ spelling the path readings above resolve. The two `/etc` families match by
611
+ prefix, so `/etc/crontab` is covered along with everything under `/etc/cron.d`,
612
+ and a sibling that merely starts like the directory (say `/etc/cronography`) is
613
+ refused too — the fail-closed direction. Writes spelled any other way (`sed -i`,
614
+ `rsync`, cmd.exe's `copy`, `move` and `robocopy`, a program handed to an
615
+ interpreter, a path the target computes) are not on this list; class and approval
616
+ govern them, and the operator denylist can close them. Add your own patterns via
617
+ the policy denylist; an invalid pattern fails at
618
+ startup rather than degrading silently.
601
619
 
602
620
  ### Approval Modes
603
621
 
@@ -693,8 +711,10 @@ The request shape is modeled on the AuthZEN Access Evaluation contract:
693
711
  For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
694
712
  the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
695
713
  two readings the local denylist tests, so a rule can match a canonical spelling
696
- without parsing `command` or reimplementing either reading in Rego. Other tools
697
- omit all three, and so does an empty path. `remotePath` is **as given** —
714
+ without parsing `command` or reimplementing either reading in Rego. The two
715
+ upload tools add `remoteWrite: true`, the fact the local policy refuses the
716
+ never-allowed write paths on — a rule can gate writes without parsing `command`
717
+ either. Other tools omit all four, and so does an empty path. `remotePath` is **as given** —
698
718
  `/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
699
719
  misses it: match trailing segments, or match the normalized keys.
700
720
  `remotePathWindows` keeps the case it was given; `lower()` both sides in the
@@ -1,5 +1,6 @@
1
1
  import { AWK_NAMES, readAwkInvocation } from './awk.js';
2
2
  import { matchesGrammar } from './reader-grammar.js';
3
+ import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
3
4
  /**
4
5
  * Anything through which the shell can start a second command.
5
6
  *
@@ -588,7 +589,10 @@ const READ_ONLY_SYNTHETIC = new Set(['sftp:list', 'sftp:download']);
588
589
  /**
589
590
  * Commands that are never allowed, whatever the role or approval policy.
590
591
  *
591
- * This is the policy engine's denylist — the single definition of it. The
592
+ * This is the policy engine's denylist — the regex half of it. The word-based
593
+ * rules live below in FORBIDDEN_RULES, and the write-path families in
594
+ * never-allowed-writes.ts, which both the rules and the engine's resource
595
+ * check read. The
592
596
  * engine used to keep a parallel copy as regex *strings*, which had already
593
597
  * drifted (it was missing the fork bomb and the recursive chown, and its `rm`
594
598
  * pattern was narrower). Anything here is also destructive for classification
@@ -634,9 +638,6 @@ const FORBIDDEN_PATTERNS = [
634
638
  // against the target host rather than against this server, which is not worth either
635
639
  // trade — but it is a hole, not a closed door.
636
640
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
637
- />\s*\/etc\/cron/,
638
- />\s*\/etc\/systemd/,
639
- />\s*~\/.ssh\/authorized_keys/,
640
641
  /\biptables\s+-F\b/,
641
642
  /\bchmod\s+-R\s+777\s+\//,
642
643
  ];
@@ -1483,9 +1484,20 @@ function pipesDownloadIntoShell(command, dialect = 'posix') {
1483
1484
  return false;
1484
1485
  return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
1485
1486
  }
1486
- /** `dd … of=/dev/sda` — writing an image straight onto a block device. */
1487
+ /**
1488
+ * `dd … of=/dev/sda` — writing an image straight onto a block device.
1489
+ *
1490
+ * Head via `effectiveCommandIndex`, not `parseSegments`: the privilege-prefix-only
1491
+ * machine missed a wrapped `env dd of=/dev/sda` (measured in review) while the
1492
+ * never-allowed write scan next door caught `env cp` — two dd readers two rules
1493
+ * apart must not answer through different head-extraction machines.
1494
+ */
1487
1495
  function writesToDevice(command, dialect = 'posix') {
1488
- return parseSegments(command, dialect).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1496
+ return tokenizeSegments(command, dialect).some((words) => {
1497
+ const idx = effectiveCommandIndex(words);
1498
+ return idx !== -1 && stripPath(words[idx]) === 'dd'
1499
+ && words.slice(idx + 1).some((a) => a.startsWith('of=/dev/'));
1500
+ });
1489
1501
  }
1490
1502
  /**
1491
1503
  * `chown -R … /` — a recursive chown whose target is the filesystem root.
@@ -1501,6 +1513,309 @@ function chownsRoot(command, dialect = 'posix') {
1501
1513
  return positional[positional.length - 1] === '/';
1502
1514
  });
1503
1515
  }
1516
+ /**
1517
+ * One dequoted word starting at `start`, as the redirect-target reader needs it.
1518
+ *
1519
+ * Stops at whitespace, at a character that begins another shell construct (`; | & < >
1520
+ * ( )`), or at the end. Quotes are dropped and a closing quote does not end the
1521
+ * word — the shell concatenates what follows it, so `> "/etc/cron.d"/x`,
1522
+ * `> ""~/x` and `> a"b"c` all read as one target (the review round measured the
1523
+ * `break`-on-close reading as a bypass: `> ""~/.ssh/authorized_keys` fell through).
1524
+ * The quote/escape state machine is the same one `tokenizeSegmentsDetailed` runs
1525
+ * (both keyed on DOUBLE_QUOTE_ESCAPABLE); a dialect fix must land in both loops.
1526
+ */
1527
+ function readDequotedWord(command, start, dialect) {
1528
+ const n = command.length;
1529
+ if (start >= n || /[\s;&|<>()]/.test(command[start]))
1530
+ return '';
1531
+ let word = '';
1532
+ let quote = command[start] === '"' || command[start] === "'" ? command[start] : null;
1533
+ let escaped = false;
1534
+ for (let i = quote === null ? start : start + 1; i < n; i++) {
1535
+ const ch = command[i];
1536
+ if (escaped) {
1537
+ word += ch;
1538
+ escaped = false;
1539
+ continue;
1540
+ }
1541
+ if (dialect === 'posix' && ch === '\\') {
1542
+ if (quote === "'") {
1543
+ word += ch;
1544
+ continue;
1545
+ }
1546
+ if (quote === '"') {
1547
+ const next = command[i + 1];
1548
+ if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
1549
+ escaped = true;
1550
+ continue;
1551
+ }
1552
+ word += ch;
1553
+ continue;
1554
+ }
1555
+ escaped = true;
1556
+ continue;
1557
+ }
1558
+ if (quote) {
1559
+ if (ch === quote) {
1560
+ quote = null;
1561
+ continue;
1562
+ }
1563
+ word += ch;
1564
+ continue;
1565
+ }
1566
+ if (ch === '"' || ch === "'") {
1567
+ quote = ch;
1568
+ continue;
1569
+ }
1570
+ if (/[\s;&|<>()]/.test(ch))
1571
+ break;
1572
+ word += ch;
1573
+ }
1574
+ return word;
1575
+ }
1576
+ /**
1577
+ * Every shell-redirect target in `command`, dequoted the way the shell will pass it.
1578
+ *
1579
+ * A redirect is an unquoted `>` — alone (`> f`), attached to the word it ends
1580
+ * (`echo x>f`, which bash also reads as a redirect), doubled (`>>`), with an fd
1581
+ * prefix (`2>`, `2>>`), with the both-streams spellings (`&>`, `&>>`, `>&`) or
1582
+ * with the noclobber-override marker (`>|`). A `>` inside quotes is data
1583
+ * (`echo "> f"` prints a string) and yields nothing, which is what separates this
1584
+ * scan from the raw regexes it replaces: those fired on the quoted text too, so a
1585
+ * commit message quoting a redirect was a refusal nobody could switch off
1586
+ * (measured on main).
1587
+ *
1588
+ * The target is the next word after the operator, quoted or attached. An operator
1589
+ * with nothing readable after it is a syntax error the remote shell would report,
1590
+ * and yields nothing. `>(...)` process substitution yields nothing here too — its
1591
+ * inner command is a carrier `nestedCommands` already reads.
1592
+ */
1593
+ function redirectTargets(command, dialect) {
1594
+ const targets = [];
1595
+ const n = command.length;
1596
+ let quote = null;
1597
+ let escaped = false;
1598
+ for (let i = 0; i < n; i++) {
1599
+ const ch = command[i];
1600
+ if (escaped) {
1601
+ escaped = false;
1602
+ continue;
1603
+ }
1604
+ if (dialect === 'posix' && ch === '\\' && quote !== "'") {
1605
+ escaped = true;
1606
+ continue;
1607
+ }
1608
+ if (quote) {
1609
+ if (ch === quote)
1610
+ quote = null;
1611
+ continue;
1612
+ }
1613
+ if (ch === '"' || ch === "'") {
1614
+ quote = ch;
1615
+ continue;
1616
+ }
1617
+ if (ch !== '>' && !(ch === '&' && command[i + 1] === '>'))
1618
+ continue;
1619
+ let j = ch === '&' ? i + 2 : i + 1;
1620
+ if (command[j] === '>')
1621
+ j++; // >>
1622
+ if (command[j] === '&')
1623
+ j++; // >& fd duplication
1624
+ if (command[j] === '|')
1625
+ j++; // >| noclobber override
1626
+ while (j < n && /\s/.test(command[j]))
1627
+ j++;
1628
+ const word = readDequotedWord(command, j, dialect);
1629
+ if (word !== '')
1630
+ targets.push(word);
1631
+ i = j - 1;
1632
+ }
1633
+ return targets;
1634
+ }
1635
+ /**
1636
+ * xargs's own value-taking flags — one tool's option set, enumerable the way
1637
+ * `PREFIX_VALUE_FLAGS` holds sudo's. `effectiveCommandIndex` steps a wrapper's
1638
+ * flags with *sudo's* set, so `xargs -I {}` leaves `{}` looking like the command
1639
+ * and the verb xargs really runs hides behind it (measured in review:
1640
+ * `xargs -I {} cp {} /root/.ssh/authorized_keys` passed).
1641
+ */
1642
+ const XARGS_VALUE_FLAGS = new Set(['-I', '-J', '-d', '-E', '-n', '-s', '-P', '-R', '-L', '-S']);
1643
+ /**
1644
+ * The command index for a segment whose wrapper may be `xargs`, which the plain
1645
+ * `effectiveCommandIndex` cannot step past. Same walk, one addition; runs
1646
+ * alongside the plain index so nothing else changes.
1647
+ */
1648
+ function indexPastXargsFlags(words) {
1649
+ let i = 0;
1650
+ while (i < words.length) {
1651
+ const raw = words[i];
1652
+ const word = stripPath(unquote(raw));
1653
+ if (word === 'xargs') {
1654
+ i++;
1655
+ while (i < words.length && words[i].startsWith('-')) {
1656
+ const consumesValue = XARGS_VALUE_FLAGS.has(words[i]);
1657
+ i++;
1658
+ if (consumesValue)
1659
+ i++;
1660
+ }
1661
+ continue;
1662
+ }
1663
+ if (PRIVILEGE_PREFIXES.has(word) || EXEC_WRAPPERS.has(word)) {
1664
+ i++;
1665
+ continue;
1666
+ }
1667
+ if (ASSIGNMENT.test(raw) || raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
1668
+ i++;
1669
+ continue;
1670
+ }
1671
+ return i;
1672
+ }
1673
+ return -1;
1674
+ }
1675
+ /**
1676
+ * The positional operands of the copy family, with every option spelling that
1677
+ * carries a value stepped over.
1678
+ *
1679
+ * GNU getopt permutes options anywhere in the argv — `install k
1680
+ * /root/.ssh/authorized_keys -m 600` is ordinary usage — so a value-taking flag
1681
+ * trailing the destination must not have its value read as the last positional
1682
+ * (measured in review: it displaced the destination and the write was missed).
1683
+ * `-t`/`--target-directory` in all three spellings (`-t DIR`, `-tDIR`,
1684
+ * `--target-directory=DIR`, `--target-directory DIR`) is stepped here too; the
1685
+ * caller reads its value as the destination itself.
1686
+ */
1687
+ function copyPositionals(args) {
1688
+ const positional = [];
1689
+ for (let i = 0; i < args.length; i++) {
1690
+ const arg = args[i];
1691
+ if (arg === '-t' || arg === '--target-directory') {
1692
+ i++;
1693
+ continue;
1694
+ }
1695
+ if (/^-t.+/.test(arg) || /^--target-directory=/.test(arg))
1696
+ continue;
1697
+ // An attached short value (`-m600`) needs no line of its own: the generic
1698
+ // `-` skip below already keeps it out of the positionals.
1699
+ if (arg === '-m' || arg === '-o' || arg === '-g' || arg === '-S' || arg === '-Z') {
1700
+ i++;
1701
+ continue;
1702
+ }
1703
+ if (/^--(mode|owner|group|suffix|context|sparse)=/.test(arg))
1704
+ continue;
1705
+ if (arg === '--mode' || arg === '--owner' || arg === '--group' || arg === '--suffix'
1706
+ || arg === '--context' || arg === '--sparse') {
1707
+ i++;
1708
+ continue;
1709
+ }
1710
+ if (arg.startsWith('-'))
1711
+ continue;
1712
+ positional.push(arg);
1713
+ }
1714
+ return positional;
1715
+ }
1716
+ /**
1717
+ * The destination operands of the verbs whose argv names the file they write.
1718
+ *
1719
+ * A closed list, on purpose. `tee` writes every non-flag operand; `cp`, `mv` and
1720
+ * `install` write their destination operand — the value of `-t`/
1721
+ * `--target-directory` in any of its spellings when present, else the last
1722
+ * positional — and, because that operand may name a directory the sources are
1723
+ * copied *into*, the same joined with each source's basename, so `cp
1724
+ * authorized_keys ~/.ssh/` is caught rather than only `cp k
1725
+ * ~/.ssh/authorized_keys`. `install -d` creates every operand. `dd of=` writes
1726
+ * the value. Every other verb is a documented residual: a guess about an
1727
+ * unrecognised binary's operands must not reach this list (the
1728
+ * `speculativeOperands: false` ruling), and #230's decision 1 already ruled that
1729
+ * extracting paths from shell text can never be complete — so the README lists
1730
+ * these forms and names the rest as governed by class and approval instead.
1731
+ *
1732
+ * `env cp …`, `sudo cp …`, `timeout 5 cp …` and `xargs -I {} cp …` reach the
1733
+ * verb through `effectiveCommandIndex` and its xargs-stepping sibling.
1734
+ */
1735
+ function writeVerbTargets(command, dialect) {
1736
+ const targets = [];
1737
+ for (const words of tokenizeSegments(command, dialect)) {
1738
+ const heads = [effectiveCommandIndex(words), indexPastXargsFlags(words)]
1739
+ .filter((i, idx, all) => i !== -1 && all.indexOf(i) === idx);
1740
+ for (const idx of heads) {
1741
+ const head = stripPath(words[idx]);
1742
+ const args = words.slice(idx + 1);
1743
+ if (head === 'tee') {
1744
+ targets.push(...args.filter((a) => !a.startsWith('-')));
1745
+ continue;
1746
+ }
1747
+ if (head === 'dd') {
1748
+ for (const arg of args) {
1749
+ if (arg.startsWith('of='))
1750
+ targets.push(arg.slice(3));
1751
+ }
1752
+ continue;
1753
+ }
1754
+ if (head === 'cp' || head === 'mv' || head === 'install') {
1755
+ const positional = copyPositionals(args);
1756
+ if (head === 'install' && args.some((a) => a === '-d' || a === '--directory')) {
1757
+ targets.push(...positional);
1758
+ continue;
1759
+ }
1760
+ const tIndex = args.findIndex((a) => a === '-t' || a === '--target-directory'
1761
+ || /^-t.+/.test(a) || a.startsWith('--target-directory='));
1762
+ let dest = null;
1763
+ let sources = positional;
1764
+ if (tIndex !== -1) {
1765
+ const flag = args[tIndex];
1766
+ if (flag === '-t' || flag === '--target-directory') {
1767
+ dest = args[tIndex + 1] || null;
1768
+ }
1769
+ else {
1770
+ const eq = flag.indexOf('=');
1771
+ dest = eq === -1 ? flag.slice(2) : flag.slice(eq + 1);
1772
+ }
1773
+ }
1774
+ else if (positional.length >= 2) {
1775
+ dest = positional[positional.length - 1];
1776
+ sources = positional.slice(0, -1);
1777
+ }
1778
+ if (dest === null || dest === '')
1779
+ continue;
1780
+ targets.push(dest);
1781
+ for (const src of sources) {
1782
+ const base = stripPath(src);
1783
+ if (base !== '')
1784
+ targets.push(`${dest}/${base}`);
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+ return targets;
1790
+ }
1791
+ /**
1792
+ * The never-allowed family cache — same shape and reason as `normalizedCache`:
1793
+ * three ForbiddenRule entries ask the same (command, dialect) question in a row,
1794
+ * and each ask re-ran both scans over the whole command. Keyed with the dialect
1795
+ * because both readings of one command are asked in the same row.
1796
+ */
1797
+ const neverAllowedWriteCache = new Map();
1798
+ /**
1799
+ * Which never-allowed family this command's certain write forms name, or null.
1800
+ * Both dialects are tried by the caller (findForbiddenMatchInDialect), and nested
1801
+ * commands reach this through the same recursion every other rule uses.
1802
+ */
1803
+ function findNeverAllowedWriteFamily(command, dialect) {
1804
+ const key = `${dialect}\u0000${command}`;
1805
+ if (neverAllowedWriteCache.has(key))
1806
+ return neverAllowedWriteCache.get(key);
1807
+ let family = null;
1808
+ for (const candidate of [...redirectTargets(command, dialect), ...writeVerbTargets(command, dialect)]) {
1809
+ family = findNeverAllowedWriteTarget(candidate);
1810
+ if (family !== null)
1811
+ break;
1812
+ }
1813
+ neverAllowedWriteCache.set(key, family);
1814
+ if (neverAllowedWriteCache.size > 8) {
1815
+ neverAllowedWriteCache.delete(neverAllowedWriteCache.keys().next().value);
1816
+ }
1817
+ return family;
1818
+ }
1504
1819
  const FORBIDDEN_RULES = [
1505
1820
  ...FORBIDDEN_PATTERNS.map((re) => ({
1506
1821
  label: String(re),
@@ -1516,6 +1831,10 @@ const FORBIDDEN_RULES = [
1516
1831
  },
1517
1832
  { label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
1518
1833
  { label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
1834
+ ...['/etc/cron', '/etc/systemd', 'authorized_keys'].map((family) => ({
1835
+ label: neverAllowedWriteLabel(family),
1836
+ test: (command, dialect) => findNeverAllowedWriteFamily(command, dialect) === family,
1837
+ })),
1519
1838
  ];
1520
1839
  /**
1521
1840
  * Which never-allowed rule this command trips, or null. The single entry point:
@@ -2109,9 +2428,10 @@ function classifyCommandInDialect(command, depth, dialect) {
2109
2428
  if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
2110
2429
  highest = parsed;
2111
2430
  }
2112
- // `readOnlyRejection` only ever describes the outer command's own grammar
2113
- // rejection, and only means anything when that is also what decided the
2114
- // final class. `highest === outer` (reference equality) is exactly that
2431
+ // `readOnlyRejection` only ever describes why the outer command's reader was
2432
+ // refused read-only — a grammar rejection or a shell metacharacter, see
2433
+ // `ReadOnlyRejection` — and only means anything when that is also what decided
2434
+ // the final class. `highest === outer` (reference equality) is exactly that
2115
2435
  // condition: the synthetic floor and the nested-command loop above only
2116
2436
  // ever replace `highest` with something of strictly higher rank than
2117
2437
  // `outer`'s, so if either one fired, `highest` is no longer `outer` and the
@@ -2153,8 +2473,20 @@ function classifyOuter(trimmed, dialect) {
2153
2473
  const twoWordPrefix = words.slice(0, 2).join(' ');
2154
2474
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
2155
2475
  || READ_ONLY_SYNTHETIC.has(binary)) {
2156
- if (SHELL_CONTROL_CHARS.test(trimmed)) {
2157
- return { binary, fullCommand, class: 'safe' };
2476
+ // Whichever key matched decides both which entry answers and how many
2477
+ // leading words are the command name rather than an argument: a two-word
2478
+ // entry's grammar reads words[2..], a one-word entry's reads words[1..]. An
2479
+ // SFTP verb's two-word prefix is never a key, so it is named by itself.
2480
+ const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
2481
+ const readerName = matchedTwoWord ? twoWordPrefix : binary;
2482
+ const metacharacter = SHELL_CONTROL_CHARS.exec(trimmed);
2483
+ if (metacharacter) {
2484
+ return {
2485
+ binary,
2486
+ fullCommand,
2487
+ class: 'safe',
2488
+ readOnlyRejection: { binary: readerName, word: metacharacter[0], cause: 'shell-metacharacter' },
2489
+ };
2158
2490
  }
2159
2491
  // The SFTP verbs are synthesised by the tool layer, never typed by a caller,
2160
2492
  // so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
@@ -2162,11 +2494,6 @@ function classifyOuter(trimmed, dialect) {
2162
2494
  if (READ_ONLY_SYNTHETIC.has(binary)) {
2163
2495
  return { binary, fullCommand, class: 'read-only' };
2164
2496
  }
2165
- // Whichever key matched decides both which entry answers and how many
2166
- // leading words are the command name rather than an argument: a two-word
2167
- // entry's grammar reads words[2..], a one-word entry's reads words[1..].
2168
- const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
2169
- const readerName = matchedTwoWord ? twoWordPrefix : binary;
2170
2497
  const entry = READERS[readerName];
2171
2498
  const argWords = words.slice(matchedTwoWord ? 2 : 1);
2172
2499
  // An unquoted glob word is not a word this grammar can vouch for: the
@@ -2182,7 +2509,7 @@ function classifyOuter(trimmed, dialect) {
2182
2509
  binary,
2183
2510
  fullCommand,
2184
2511
  class: 'safe',
2185
- readOnlyRejection: { binary: readerName, word: globWord },
2512
+ readOnlyRejection: { binary: readerName, word: globWord, cause: 'grammar' },
2186
2513
  };
2187
2514
  }
2188
2515
  }
@@ -2192,7 +2519,7 @@ function classifyOuter(trimmed, dialect) {
2192
2519
  binary,
2193
2520
  fullCommand,
2194
2521
  class: 'safe',
2195
- readOnlyRejection: { binary: readerName, word: match.word },
2522
+ readOnlyRejection: { binary: readerName, word: match.word, cause: 'grammar' },
2196
2523
  };
2197
2524
  }
2198
2525
  return { binary, fullCommand, class: 'read-only' };
@@ -2200,17 +2527,40 @@ function classifyOuter(trimmed, dialect) {
2200
2527
  return { binary, fullCommand, class: 'safe' };
2201
2528
  }
2202
2529
  /**
2203
- * The sentence a refusal appends when it carries a `readOnlyRejection`, worded
2204
- * so the reason names the binary that would have qualified and the argv word
2205
- * that cost it: "is not accepted there" rather than "is not one of them",
2206
- * because most rejections are operand rules (`hostname NAME`, `sort /data`) or
2207
- * a listed value flag missing its value, where "is not one of them" would be
2208
- * false — it may be a perfectly good flag name, just not one bare or in this
2209
- * position. One function so the engine's role-binding denial and
2210
- * `read-command`'s `enforceClass` refusal never drift into two spellings of
2211
- * the same sentence.
2530
+ * The sentence a refusal appends when it carries a `readOnlyRejection`, in one of
2531
+ * two shapes chosen by its `cause`. Both name the reader that would have qualified
2532
+ * and what cost it the class.
2533
+ *
2534
+ * - `grammar`: names the argv word, as "is not accepted there" rather than "is not
2535
+ * one of them", because most rejections are operand rules (`hostname NAME`,
2536
+ * `sort /data`) or a listed value flag missing its value, where "is not one of
2537
+ * them" would be false — it may be a perfectly good flag name, just not one bare
2538
+ * or in this position.
2539
+ * - `shell-metacharacter`: names the character, and for the SFTP verbs says it is
2540
+ * in the remote path (#224).
2541
+ *
2542
+ * One function so the engine's role-binding denial and `read-command`'s
2543
+ * `enforceClass` refusal never drift into two spellings of the same sentence.
2212
2544
  */
2213
2545
  function formatReadOnlyRejection(rejection) {
2546
+ if (rejection.cause === 'shell-metacharacter') {
2547
+ // `\n` and `\r` are in SHELL_CONTROL_CHARS, and quoted raw they would break the
2548
+ // sentence across lines or render as nothing. A backtick is in it too, and inside
2549
+ // single backticks it would print as three in a row; double backticks with
2550
+ // padding is Markdown's own way to quote one.
2551
+ const char = rejection.word.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
2552
+ const quoted = char === '`' ? '`` ` ``' : `\`${char}\``;
2553
+ // The SFTP verbs' operand is a path the tool layer built the command from, so the
2554
+ // path is what the caller can change — not "the command", which they never typed.
2555
+ // The verb is named as the audit record spells it (`sftp:list`), not as the tool
2556
+ // (`sftp-list`), so the refusal and the record it is filed under read the same.
2557
+ if (READ_ONLY_SYNTHETIC.has(rejection.binary)) {
2558
+ return `The remote path contains ${quoted}, which counts as a shell metacharacter here, ` +
2559
+ `so \`${rejection.binary}\` is read-only only for a path without one.`;
2560
+ }
2561
+ return `\`${rejection.binary}\` is read-only only when the command contains no shell metacharacter; ` +
2562
+ `${quoted} is one.`;
2563
+ }
2214
2564
  // The rejected word can be the empty string — a quoted empty operand
2215
2565
  // (`hostname ""`) — and two bare backticks would render as a blank where
2216
2566
  // the word that cost the class should be. `""` is the shell's own spelling
@@ -1,6 +1,9 @@
1
1
  import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
2
2
  import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
3
+ import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
3
4
  import { OperatorError } from '../errors.js';
5
+ /** The operator-facing promise every built-in never-allowed refusal ends with. */
6
+ const NEVER_ALLOWED_TRAILER = 'This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.';
4
7
  /**
5
8
  * How long to wait for the OPA sidecar before treating it as unavailable.
6
9
  *
@@ -316,6 +319,7 @@ export class PolicyEngine {
316
319
  binary: parsed.binary,
317
320
  host: profile.host,
318
321
  remotePath,
322
+ remoteWrite: resource.remoteWrite || undefined,
319
323
  remotePathNormalized: remotePath === undefined ? undefined : normalizeRemotePath(remotePath),
320
324
  remotePathWindows: remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath),
321
325
  },
@@ -449,9 +453,9 @@ export class PolicyEngine {
449
453
  }
450
454
  }
451
455
  // A `safe` command refused here is often not a hard "no" — it is one
452
- // argv word away from `read-only`, and without this the reader concludes
453
- // the binary is forbidden outright and never learns which spelling would
454
- // have worked.
456
+ // spelling away from `read-only` (an argv word the grammar refused, or a
457
+ // shell metacharacter), and without this the reader concludes the binary is
458
+ // forbidden outright and never learns which spelling would have worked.
455
459
  if (parsed.readOnlyRejection) {
456
460
  reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
457
461
  }
@@ -505,8 +509,17 @@ export class PolicyEngine {
505
509
  findDenyMatch(command, resource) {
506
510
  const builtIn = findForbiddenMatch(command);
507
511
  if (builtIn) {
508
- return `Command matches a built-in never-allowed rule: ${builtIn}. ` +
509
- `This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.`;
512
+ return `Command matches a built-in never-allowed rule: ${builtIn}. ${NEVER_ALLOWED_TRAILER}`;
513
+ }
514
+ // The built-in never-allowed write paths, tested against a remote path the tool
515
+ // declared it writes. Runs before the early return below because the defaults
516
+ // carry no operator denylist — this check is not the operator's to switch on.
517
+ if (resource.remoteWrite && resource.remotePath) {
518
+ const target = findNeverAllowedWriteTarget(resource.remotePath);
519
+ if (target !== null) {
520
+ return `Remote path ${quotePath(resource.remotePath)} matches a built-in never-allowed rule: ` +
521
+ `${neverAllowedWriteLabel(target)}. ${NEVER_ALLOWED_TRAILER}`;
522
+ }
510
523
  }
511
524
  // Nothing below can match: skip reading the path at all, which is the commonest
512
525
  // config — the defaults carry no denylist.
@@ -0,0 +1,51 @@
1
+ import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
2
+ /**
3
+ * The `/etc` families are prefix matches with no boundary: `/etc/crontab` sits under
4
+ * `/etc/cron` the same way `/etc/cron.d/x` does, which is how the redirect regex this
5
+ * module replaces always read it. A sibling that merely starts like the directory
6
+ * (`/etc/cronography`) fires too — over-refusal in the fail-closed direction this
7
+ * engine takes everywhere else.
8
+ */
9
+ const NEVER_DIRS = ['/etc/cron', '/etc/systemd'];
10
+ /** The keys family: any path whose last two segments are `.ssh/authorized_keys`. */
11
+ const KEYS_TAIL = '/.ssh/authorized_keys';
12
+ /**
13
+ * The refusal wording for a family, defined once because two check sites quote it:
14
+ * the classifier's ForbiddenRule label and the engine's built-in deny reason.
15
+ */
16
+ export function neverAllowedWriteLabel(target) {
17
+ switch (target) {
18
+ case '/etc/cron': return 'writing to /etc/cron';
19
+ case '/etc/systemd': return 'writing to /etc/systemd';
20
+ case 'authorized_keys': return "writing to any user's authorized_keys";
21
+ }
22
+ }
23
+ function familyOf(path) {
24
+ for (const dir of NEVER_DIRS) {
25
+ if (path.startsWith(dir))
26
+ return dir;
27
+ }
28
+ if (path.endsWith(KEYS_TAIL) || path === KEYS_TAIL.slice(1))
29
+ return 'authorized_keys';
30
+ return null;
31
+ }
32
+ /**
33
+ * Whether `path` names one of the never-allowed write targets, in any spelling the
34
+ * path readings resolve.
35
+ *
36
+ * The same three readings `[policy].denylist` sees (#230): the path as given; a
37
+ * lexically normalized reading (`//`, `/./`, `..`, trailing separators, `\` as a
38
+ * separator); and the Windows reading of it, case-insensitively — so `Authorized_Keys.`,
39
+ * `authorized_keys::$DATA` and backslash spellings reach the same refusal. On a POSIX
40
+ * target some of those are different files; refusing them is the safe direction, the
41
+ * same trade the operator denylist already makes.
42
+ *
43
+ * What this does not do is resolve anything on the target: a relative path stays
44
+ * relative, symlinks are not followed, and an 8.3 short name is not expanded (#230's
45
+ * documented residuals, inherited here).
46
+ */
47
+ export function findNeverAllowedWriteTarget(path) {
48
+ return familyOf(path)
49
+ ?? familyOf(normalizeRemotePath(path))
50
+ ?? familyOf(normalizeRemotePathForWindows(path).toLowerCase());
51
+ }
@@ -53,7 +53,7 @@ export function registerFileTools({ server }, pipeline) {
53
53
  profile,
54
54
  extra,
55
55
  synthetic: true,
56
- resource: { remotePath },
56
+ resource: { remotePath, remoteWrite: true },
57
57
  preCheck: () => { sanitizeRemotePath(remotePath); },
58
58
  }, async (rt) => {
59
59
  await new SftpClient(rt.conn).upload({ remotePath, content });
@@ -170,11 +170,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
170
170
  const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName, opts.resource);
171
171
  state.evaluation = evaluation;
172
172
  if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
173
- // A command that fell to `safe` because a reader's grammar rejected one
174
- // of its argv words is one spelling away from the class this tool
175
- // demands — say which word, or the caller only learns that the tool
176
- // refused it, not why the reader that ran the exact same binary
177
- // would not have.
173
+ // A command that fell to `safe` because a reader was refused read-only —
174
+ // its grammar rejected an argv word, or the command carries a shell
175
+ // metacharacter — is one spelling away from the class this tool demands.
176
+ // Say which word or character, or the caller only learns that the tool
177
+ // refused it, not why the reader that ran the exact same binary would
178
+ // not have.
178
179
  const rejectionSuffix = evaluation.readOnlyRejection
179
180
  ? ` ${formatReadOnlyRejection(evaluation.readOnlyRejection)}`
180
181
  : '';
@@ -256,7 +256,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
256
256
  profile,
257
257
  extra,
258
258
  synthetic: true,
259
- resource: { remotePath },
259
+ resource: { remotePath, remoteWrite: true },
260
260
  preCheck: () => { sanitizeRemotePath(remotePath); checkMode(mode); },
261
261
  }, async (rt) => {
262
262
  const maxBytes = rt.conn.profile.transferMaxBytes;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.18.0",
4
+ "version": "2.19.1",
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",
@@ -14,7 +14,7 @@
14
14
  "typecheck": "tsc --noEmit -p tsconfig.test.json",
15
15
  "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
16
16
  "changeset": "changeset",
17
- "version": "changeset version && node scripts/sync-server-json.mjs",
17
+ "version": "changeset version && node scripts/sync-server-json.mjs && node scripts/sync-lockfile-version.mjs",
18
18
  "test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
19
19
  "test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
20
20
  "test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",
@@ -27,7 +27,7 @@
27
27
  "build"
28
28
  ],
29
29
  "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.30.0",
30
+ "@modelcontextprotocol/sdk": "^1.31.0",
31
31
  "@opentelemetry/api": "^1.9.1",
32
32
  "@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
33
33
  "@opentelemetry/resources": "^2.9.0",