ssh-mcp 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -595,9 +595,19 @@ there. ``), so the caller can see which spelling of the same command still works
595
595
 
596
596
  A separate **forbidden** list is never allowed, whatever the role or approval
597
597
  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.
598
+ writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
599
+ `iptables -F`, and recursive `chmod 777 /` / `chown /`. A write *names* one of
600
+ those paths when it is the remote path of an upload tool, the target of a shell
601
+ redirect, or a destination of `tee`, `cp`, `mv`, `install` or `dd of=` — in any
602
+ spelling the path readings above resolve. The two `/etc` families match by
603
+ prefix, so `/etc/crontab` is covered along with everything under `/etc/cron.d`,
604
+ and a sibling that merely starts like the directory (say `/etc/cronography`) is
605
+ refused too — the fail-closed direction. Writes spelled any other way (`sed -i`,
606
+ `rsync`, cmd.exe's `copy`, `move` and `robocopy`, a program handed to an
607
+ interpreter, a path the target computes) are not on this list; class and approval
608
+ govern them, and the operator denylist can close them. Add your own patterns via
609
+ the policy denylist; an invalid pattern fails at
610
+ startup rather than degrading silently.
601
611
 
602
612
  ### Approval Modes
603
613
 
@@ -693,8 +703,10 @@ The request shape is modeled on the AuthZEN Access Evaluation contract:
693
703
  For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
694
704
  the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
695
705
  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** —
706
+ without parsing `command` or reimplementing either reading in Rego. The two
707
+ upload tools add `remoteWrite: true`, the fact the local policy refuses the
708
+ never-allowed write paths on — a rule can gate writes without parsing `command`
709
+ either. Other tools omit all four, and so does an empty path. `remotePath` is **as given** —
698
710
  `/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
699
711
  misses it: match trailing segments, or match the normalized keys.
700
712
  `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:
@@ -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
  },
@@ -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 });
@@ -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.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",