ssh-mcp 2.17.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
@@ -180,12 +180,13 @@ both and an auditor cannot tell which set of bytes landed. The digest is 128
180
180
  bits rather than a short prefix because that claim has to hold against a caller
181
181
  who picks both payloads, not only against an accidental repeat.
182
182
 
183
- The path comes last in that string on purpose. `[policy].denylist` patterns are
184
- matched against the whole approved string, so a rule anchored on the path —
185
- `authorized_keys$`, the natural way to write "nothing may write here" — keeps
186
- working. A rule anchored on the *whole* string (`^sftp:upload /root/.*$`) does
187
- not: it was coupled to a format that is ours to change, and this release changes
188
- it. Anchor on the path segment instead.
183
+ `[policy].denylist` patterns are tested against that whole string and, for every
184
+ SFTP tool, against the remote path on its own — as given, lexically normalized,
185
+ and read the way Windows reads it (see "Policy Engine"). A rule written for the
186
+ path, such as `authorized_keys$`, therefore does not depend on where the string
187
+ puts it or on how the file is spelled. A rule anchored on the *whole* string
188
+ (`^sftp:upload /root/.*$`) still does: that layout is ours to change, so anchor
189
+ on the path instead.
189
190
 
190
191
  `sftp-upload-file` and `sftp-download-file` stream between the remote host and
191
192
  local disk instead. Neither the bytes nor a base64 encoding of them ever reaches
@@ -330,7 +331,7 @@ transferTimeoutMs = 60000 # per-profile override
330
331
  # operator on prod and leaves operator's other tiers, and viewer and admin,
331
332
  # on their defaults.
332
333
  [policy]
333
- denylist = ["^terraform\\s+destroy"]
334
+ denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
334
335
 
335
336
  [policy.roleBindings.operator]
336
337
  prod = ["read-only", "safe", "destructive"]
@@ -491,9 +492,52 @@ never-allowed list rather than replacing it:
491
492
 
492
493
  ```toml
493
494
  [policy]
494
- denylist = ["^terraform\\s+destroy"]
495
+ denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
495
496
  ```
496
497
 
498
+ Each pattern is a regular expression tested against the command string of every
499
+ call. For the five SFTP tools it is also tested against the remote path on its
500
+ own, three ways: as given; in a normalized reading, where `//` and `/./`
501
+ collapse, `..` resolves against the segment before it, a trailing `/` is
502
+ dropped, and `\` is read as `/`; and in a **Windows reading**, tested
503
+ case-insensitively. The refusal says which matched.
504
+
505
+ The Windows reading is what Win32 resolves a path to, measured over SFTP on
506
+ Windows 11 (build 26200): the case variants, a trailing run of dots and spaces,
507
+ and the `::$DATA` default-stream spelling all reach the same file, so a rule
508
+ that caught one spelling and not another was a bypass, not a distinction. On
509
+ top of the normalized reading it strips those spellings, roots a
510
+ drive-relative path (`C:x/…`) at its drive — where it actually resolves
511
+ depends on process state this server cannot see, and rooting is the fail-closed
512
+ reading — drops `\\?\` and `\\.\` device prefixes, and keeps a UNC root
513
+ (`\\server\share\…` → `//server/share/…`). Tested case-insensitively, it also
514
+ catches `Authorized_Keys` with a rule written for `authorized_keys`; on a POSIX
515
+ target those are different files, but erring toward refusal is the direction
516
+ this engine takes everywhere else too.
517
+
518
+ What the readings do not do:
519
+
520
+ - **resolve anything on the target.** A relative path stays relative, so a rule
521
+ anchored on `/root/` does not see `.ssh/authorized_keys`; symlinks are not
522
+ followed; and an 8.3 short name (`AUTHOR~1`) is not expanded — no lexical
523
+ reading can, it takes the target's own directory listing to know the long
524
+ name.
525
+ - **test the local path** of `sftp-upload-file` or `sftp-download-file` on its
526
+ own. That side is confined by `transferRoot`. The local path does end those two
527
+ tools' command strings, so a pattern anchored on the end of the string can
528
+ still match it there, as it always could.
529
+
530
+ So anchor a path rule on its trailing segments: `\.ssh/authorized_keys$` matches
531
+ the absolute, relative and Windows spellings alike — including the case,
532
+ trailing-dot and `::$DATA` variants of each. A pattern written for a command is
533
+ tested against SFTP paths too — `^rm` refuses an `sftp-download` of a file
534
+ called `rmlist.txt`.
535
+
536
+ One performance note: patterns are ordinary regular expressions run against
537
+ caller-supplied path bytes — bounded by the remote-path length limit, the same
538
+ exposure the command string has always had. Prefer anchored, linear patterns; a
539
+ catastrophically backtracking one stalls the tool call it matches.
540
+
497
541
  Because role and tier names are free strings, nothing in the merge itself can
498
542
  tell a new custom role from a misspelling of an existing one. A cross-check at
499
543
  startup does, and these all fail there rather than at the point of use:
@@ -551,9 +595,19 @@ there. ``), so the caller can see which spelling of the same command still works
551
595
 
552
596
  A separate **forbidden** list is never allowed, whatever the role or approval
553
597
  policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
554
- writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and
555
- recursive `chmod 777 /` / `chown /`. Add your own patterns via the policy
556
- 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.
557
611
 
558
612
  ### Approval Modes
559
613
 
@@ -633,7 +687,7 @@ during the outage, and the only signal is a stderr line MCP clients usually disc
633
687
  "opa-unavailable"` so the audit record says the gate was down rather than implying a policy
634
688
  refused the command.
635
689
 
636
- The request shape follows the AuthZEN Access Evaluation contract:
690
+ The request shape is modeled on the AuthZEN Access Evaluation contract:
637
691
 
638
692
  ```json
639
693
  {
@@ -646,6 +700,19 @@ The request shape follows the AuthZEN Access Evaluation contract:
646
700
  }
647
701
  ```
648
702
 
703
+ For the five SFTP tools, `resource` also carries three path keys: `remotePath`,
704
+ the path as given; and `remotePathNormalized` and `remotePathWindows`, the same
705
+ two readings the local denylist tests, so a rule can match a canonical spelling
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** —
710
+ `/root//.ssh/x` arrives spelled that way, so a `startswith` rule on a directory
711
+ misses it: match trailing segments, or match the normalized keys.
712
+ `remotePathWindows` keeps the case it was given; `lower()` both sides in the
713
+ policy for case-insensitive rules. Match fields rather than the whole
714
+ `resource` object — its keys vary by tool.
715
+
649
716
  OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands by default (fail-open, to avoid locking out access); `--opaFailClosed` refuses instead. A 200 that carries no boolean `result` counts as unreachable — that is what OPA answers for an undefined document, so a misnamed package or an unactivated bundle is an outage rather than consent.
650
717
 
651
718
  Example Rego policy (`ssh-mcp.rego`):
@@ -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,5 +1,9 @@
1
1
  import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
2
+ import { normalizeRemotePath, normalizeRemotePathForWindows } from './remote-path.js';
3
+ import { findNeverAllowedWriteTarget, neverAllowedWriteLabel } from './never-allowed-writes.js';
2
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.';
3
7
  /**
4
8
  * How long to wait for the OPA sidecar before treating it as unavailable.
5
9
  *
@@ -200,7 +204,11 @@ export class PolicyEngine {
200
204
  opaTimeoutMs = DEFAULT_OPA_TIMEOUT_MS;
201
205
  /** Rate-limits the fail-open warning so one outage can't flood stderr. */
202
206
  lastOpaWarning = 0;
203
- /** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
207
+ /**
208
+ * The operator's patterns, compiled once — each twice: as written, and
209
+ * case-insensitively for the Windows reading of a remote path, where a
210
+ * case variant reaches the same file (#230). The built-ins live in classifier.ts.
211
+ */
204
212
  userPatterns;
205
213
  constructor(rules = DEFAULT_RULES) {
206
214
  this.rules = rules;
@@ -209,7 +217,7 @@ export class PolicyEngine {
209
217
  // worse than a startup failure, because nothing surfaces the degradation.
210
218
  this.userPatterns = (rules.denylist ?? []).map((pattern) => {
211
219
  try {
212
- return new RegExp(pattern);
220
+ return { exact: new RegExp(pattern), caseInsensitive: new RegExp(pattern, 'i') };
213
221
  }
214
222
  catch (err) {
215
223
  throw new OperatorError(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
@@ -226,7 +234,7 @@ export class PolicyEngine {
226
234
  this.opaUrl = url;
227
235
  this.opaFailClosed = failClosed;
228
236
  }
229
- evaluate(command, profile, _toolName) {
237
+ evaluate(command, profile, resource = {}) {
230
238
  const parsed = classifyCommand(command);
231
239
  const allowedClasses = this.getAllowedClasses(profile);
232
240
  const classAllowed = allowedClasses.includes(parsed.class);
@@ -235,7 +243,7 @@ export class PolicyEngine {
235
243
  // since a grammar-rejected reader is allowed as `safe` for most roles and
236
244
  // only refused one layer up, in the tool itself.
237
245
  const readOnlyRejection = parsed.readOnlyRejection;
238
- const denied = this.findDenyMatch(command);
246
+ const denied = this.findDenyMatch(command, resource);
239
247
  if (denied) {
240
248
  return {
241
249
  decision: 'deny',
@@ -288,8 +296,8 @@ export class PolicyEngine {
288
296
  readOnlyRejection,
289
297
  };
290
298
  }
291
- async evaluateWithOpa(command, profile, toolName) {
292
- const local = this.evaluate(command, profile, toolName);
299
+ async evaluateWithOpa(command, profile, toolName, resource = {}) {
300
+ const local = this.evaluate(command, profile, resource);
293
301
  if (local.decision === 'deny') {
294
302
  return local;
295
303
  }
@@ -298,10 +306,23 @@ export class PolicyEngine {
298
306
  }
299
307
  try {
300
308
  const parsed = classifyCommand(command);
309
+ // The path as given, plus both readings the local denylist tests, so a Rego rule
310
+ // can match a canonical spelling without reimplementing either here. Undefined for
311
+ // every tool but the SFTP ones — and for an empty path, which means no path — and
312
+ // JSON drops the keys then.
313
+ const remotePath = resource.remotePath || undefined;
301
314
  const input = {
302
315
  subject: { role: profile.role, profile: profile.name },
303
316
  action: { tool: toolName, commandClass: parsed.class },
304
- resource: { command: parsed.fullCommand, binary: parsed.binary, host: profile.host },
317
+ resource: {
318
+ command: parsed.fullCommand,
319
+ binary: parsed.binary,
320
+ host: profile.host,
321
+ remotePath,
322
+ remoteWrite: resource.remoteWrite || undefined,
323
+ remotePathNormalized: remotePath === undefined ? undefined : normalizeRemotePath(remotePath),
324
+ remotePathWindows: remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath),
325
+ },
305
326
  context: { readOnly: profile.readOnly },
306
327
  };
307
328
  const resp = await fetch(`${this.opaUrl}/v1/data/ssh/mcp/allow`, {
@@ -485,18 +506,65 @@ export class PolicyEngine {
485
506
  * group, the class and the allowed set; this path never got the same
486
507
  * treatment.
487
508
  */
488
- findDenyMatch(command) {
509
+ findDenyMatch(command, resource) {
489
510
  const builtIn = findForbiddenMatch(command);
490
511
  if (builtIn) {
491
- return `Command matches a built-in never-allowed rule: ${builtIn}. ` +
492
- `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}`;
493
513
  }
494
- for (const pattern of this.userPatterns) {
495
- if (pattern.test(command)) {
496
- return `Command matches /${pattern.source}/, a pattern from [policy].denylist in your config file. ` +
497
- `Remove or narrow it there to allow this command.`;
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
+ }
523
+ }
524
+ // Nothing below can match: skip reading the path at all, which is the commonest
525
+ // config — the defaults carry no denylist.
526
+ if (this.userPatterns.length === 0)
527
+ return null;
528
+ // The path is tested on its own as well as inside the command string, because for the
529
+ // SFTP tools that string is ours to lay out and a rule written for the path should not
530
+ // depend on where we put it (#230). An empty path means none was passed.
531
+ const remotePath = resource.remotePath || undefined;
532
+ const normalized = remotePath === undefined ? undefined : normalizeRemotePath(remotePath);
533
+ const windows = remotePath === undefined ? undefined : normalizeRemotePathForWindows(remotePath);
534
+ const fromConfig = 'a pattern from [policy].denylist in your config file. ' +
535
+ 'Remove or narrow it there to allow this command.';
536
+ for (const { exact, caseInsensitive } of this.userPatterns) {
537
+ if (exact.test(command)) {
538
+ return `Command matches /${exact.source}/, ${fromConfig}`;
539
+ }
540
+ if (remotePath === undefined)
541
+ continue;
542
+ if (exact.test(remotePath)) {
543
+ return `Remote path ${quotePath(remotePath)} matches /${exact.source}/, ${fromConfig}`;
544
+ }
545
+ if (normalized !== remotePath && exact.test(normalized)) {
546
+ return `Remote path ${quotePath(remotePath)} (read as ${quotePath(normalized)}) ` +
547
+ `matches /${exact.source}/, ${fromConfig}`;
548
+ }
549
+ // The Windows reading runs even when it string-equals the plain one, because the
550
+ // pattern is tested case-insensitively here and exactly above: on Windows the case
551
+ // variants are the same file (measured, see remote-path.ts).
552
+ if (caseInsensitive.test(windows)) {
553
+ return `Remote path ${quotePath(remotePath)} (read as ${quotePath(windows)}, the Windows reading) ` +
554
+ `matches /${caseInsensitive.source}/i, ${fromConfig}`;
498
555
  }
499
556
  }
500
557
  return null;
501
558
  }
502
559
  }
560
+ /**
561
+ * A path as it may appear in a refusal, capped. The paths are caller-supplied and can be
562
+ * as long as `MAX_REMOTE_PATH_CHARS` (4096); the refusal is rethrown to the MCP client
563
+ * and written into the audit record, where the command string already carries the full
564
+ * spelling — so the tail gives up nothing an operator needs, and a refusal stops being
565
+ * the engine's most verbose sentence by an order of magnitude.
566
+ */
567
+ function quotePath(path) {
568
+ const capped = path.length > 256 ? `${path.slice(0, 253)}…` : path;
569
+ return JSON.stringify(capped);
570
+ }
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * A remote path as a `[policy].denylist` pattern should also see it (#230).
3
+ *
4
+ * Lexical only: nothing is asked of the target, so a relative path stays relative and a
5
+ * symlink is not followed. What it removes is the spelling that keeps a rule from seeing
6
+ * a path it was written for — `//`, `/./`, `..`, a trailing separator — and it reads `\`
7
+ * as a separator too, so a Windows target's `C:\Users\a\.ssh\authorized_keys` is also
8
+ * seen as `C:/Users/a/.ssh/authorized_keys`. On a POSIX target a filename containing `\`
9
+ * can then match where it did not before; that only ever widens a refusal.
10
+ *
11
+ * An absolute path — `/…`, or one starting with a drive letter — cannot climb above its
12
+ * root. A relative path keeps a leading `..` it has nothing to resolve against.
13
+ *
14
+ * Not `node:path`: `posix.normalize` lets `..` climb above a drive root (`C:/../x` →
15
+ * `x`) and keeps a trailing separator, and `win32.normalize` answers in backslashes —
16
+ * while a deny rule needs one reading, in forward slashes, that never climbs.
17
+ */
18
+ const DRIVE_LETTER = /^[A-Za-z]:$/;
19
+ export function normalizeRemotePath(path) {
20
+ const segments = path.replace(/\\/g, '/').split('/');
21
+ let root = null;
22
+ if (segments[0] === '') {
23
+ root = '';
24
+ segments.shift();
25
+ }
26
+ else if (DRIVE_LETTER.test(segments[0])) {
27
+ root = segments.shift();
28
+ }
29
+ const kept = [];
30
+ for (const segment of segments) {
31
+ if (segment === '' || segment === '.')
32
+ continue;
33
+ if (segment === '..') {
34
+ if (kept.length > 0 && kept[kept.length - 1] !== '..')
35
+ kept.pop();
36
+ else if (root === null)
37
+ kept.push('..');
38
+ continue;
39
+ }
40
+ kept.push(segment);
41
+ }
42
+ if (root === null)
43
+ return kept.length > 0 ? kept.join('/') : '.';
44
+ if (root === '')
45
+ return kept.length > 0 ? `/${kept.join('/')}` : '/';
46
+ return kept.length > 0 ? `${root}/${kept.join('/')}` : `${root}/`;
47
+ }
48
+ /**
49
+ * The same path as Win32 would resolve it, for a denylist that must not depend on how
50
+ * the caller spelled the file (#230).
51
+ *
52
+ * Every transformation here was measured over SFTP on the Windows test VM (Windows 11
53
+ * build 26200): the spellings below reached the same file as the canonical one, so a
54
+ * rule that caught one spelling and not the other was a bypass, not a distinction.
55
+ *
56
+ * - `\` is read as `/`, as above.
57
+ * - A trailing run of dots and spaces is dropped from every segment (`authorized_keys.`
58
+ * and `authorized_keys..` are the same file). A segment that is nothing but dots
59
+ * (`a.../b`) therefore disappears, the way Win32 reads it.
60
+ * - A `::` suffix is cut (`authorized_keys::$DATA` is the file's default stream, not a
61
+ * different file). A single `:` is left alone: that names a different stream, and the
62
+ * measurement showed it does not reach the same bytes.
63
+ * - A drive-relative spelling (`C:x/…`, resolved against the drive's current directory
64
+ * on Windows) is rooted at the drive. Where it actually resolves depends on process
65
+ * state this server cannot see; rooting is the fail-closed reading.
66
+ * - `\\?\` and `\\.\` device prefixes are dropped. SFTP itself rejects them, but a rule
67
+ * should not have to know that.
68
+ * - A UNC spelling keeps its root (`\\server\share\…` → `//server/share/…`), which the
69
+ * plain reading collapses; `..` cannot climb above the share.
70
+ *
71
+ * Case is deliberately untouched — the engine tests this reading case-insensitively,
72
+ * which also covers a case-variant spelling of an otherwise identical path.
73
+ *
74
+ * Two Windows equivalences remain outside any lexical reading, because resolving them
75
+ * needs the target's own directory state, and are documented residuals in the README:
76
+ * 8.3 short names (`AUTHOR~1`), and symlinks.
77
+ *
78
+ * On a POSIX target these readings can only widen a refusal: a file genuinely named
79
+ * `authorized_keys.` is a different file there, but refusing it is the safe direction,
80
+ * exactly as with `\` above.
81
+ */
82
+ export function normalizeRemotePathForWindows(path) {
83
+ let slashed = path.replace(/\\/g, '/');
84
+ if (slashed.startsWith('//?/') || slashed.startsWith('//./'))
85
+ slashed = slashed.slice(4);
86
+ const segments = slashed.split('/');
87
+ let root = null;
88
+ let rest = segments;
89
+ if (segments[0] === '' && segments[1] === '') {
90
+ // A UNC root: the share is as high as `..` may climb.
91
+ root = `//${segments[2] ?? ''}/${segments[3] ?? ''}`;
92
+ rest = segments.slice(4);
93
+ }
94
+ else if (segments[0] === '') {
95
+ root = '/';
96
+ rest = segments.slice(1);
97
+ }
98
+ else if (/^[A-Za-z]:/.test(segments[0])) {
99
+ const drive = segments[0].slice(0, 2);
100
+ const after = segments[0].slice(2);
101
+ root = drive;
102
+ rest = after === '' ? segments.slice(1) : [after, ...segments.slice(1)];
103
+ }
104
+ const kept = [];
105
+ for (const segment of rest) {
106
+ if (segment === '' || segment === '.')
107
+ continue;
108
+ if (segment === '..') {
109
+ if (kept.length > 0 && kept[kept.length - 1] !== '..')
110
+ kept.pop();
111
+ else if (root === null)
112
+ kept.push('..');
113
+ continue;
114
+ }
115
+ const plain = segment.split('::')[0].replace(/[ .]+$/, '');
116
+ if (plain === '')
117
+ continue;
118
+ kept.push(plain);
119
+ }
120
+ if (root === null)
121
+ return kept.length > 0 ? kept.join('/') : '.';
122
+ if (root === '/')
123
+ return kept.length > 0 ? `/${kept.join('/')}` : '/';
124
+ return kept.length > 0 ? `${root}/${kept.join('/')}` : `${root}/`;
125
+ }
@@ -54,7 +54,7 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
54
54
  // `registry.getProfile()`, which refuses on an unconfigured server, and outside
55
55
  // the pipeline's try that refusal escaped without an audit record. `preCheck`
56
56
  // runs inside it.
57
- const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)), 'privileged-command');
57
+ const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)));
58
58
  if (rawEval.decision === 'deny') {
59
59
  throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
60
60
  }
@@ -42,23 +42,18 @@ export function registerFileTools({ server }, pipeline) {
42
42
  profile: z.string().optional().describe('Profile name'),
43
43
  }, { destructiveHint: true }, async ({ remotePath, content, profile }, extra) => {
44
44
  return runAudited(
45
- // Verb, then what the operation does, then the path it does it to — and the
46
- // path LAST on purpose. The engine matches `[policy].denylist` against this
47
- // whole string, so an operator rule anchored on the path (`authorized_keys$`,
48
- // the natural and portable way to write "nothing may write here") keeps
49
- // working only while the path ends the line. Measured: with the suffixes
50
- // appended after the path instead, that rule silently stops matching and the
51
- // refusal degrades to a prompt with no warning. A whole-string rule
52
- // (`^sftp:upload /root.*$`) breaks either way — it is coupled to a format
53
- // that is ours to change — so the layout is chosen for the form that is not.
54
- // `sftp:list` and `sftp:download` carry no flags, so they already read this
55
- // way and need no change.
45
+ // Verb, then what the operation does, then the path it does it to. The path
46
+ // ends the line so the string reads naturally in a prompt and an audit record;
47
+ // `[policy].denylist` no longer depends on that, since every SFTP tool hands
48
+ // its remote path to the engine on its own (#230). A rule anchored on the whole
49
+ // string (`^sftp:upload /root.*$`) is still coupled to this layout.
56
50
  `sftp:upload${OVERWRITE_FLAG}${payloadSuffix(content)} ${remotePathForAudit(remotePath)}`, {
57
51
  toolName: 'sftp-upload',
58
52
  failureClass: 'destructive',
59
53
  profile,
60
54
  extra,
61
55
  synthetic: true,
56
+ resource: { remotePath, remoteWrite: true },
62
57
  preCheck: () => { sanitizeRemotePath(remotePath); },
63
58
  }, async (rt) => {
64
59
  await new SftpClient(rt.conn).upload({ remotePath, content });
@@ -81,6 +76,7 @@ export function registerFileTools({ server }, pipeline) {
81
76
  profile,
82
77
  extra,
83
78
  synthetic: true,
79
+ resource: { remotePath },
84
80
  preCheck: () => { sanitizeRemotePath(remotePath); },
85
81
  }, async (rt) => {
86
82
  const data = await new SftpClient(rt.conn).download({ remotePath });
@@ -35,13 +35,13 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
35
35
  async function resolveConn(profileName) {
36
36
  return registry.getOrCreate(profileName);
37
37
  }
38
- async function checkPolicyAndApprove(command, profileName, toolName) {
38
+ async function checkPolicyAndApprove(command, profileName, toolName, resource = {}) {
39
39
  const span = tracer.startSpan('policy.evaluate');
40
40
  span.setAttribute('tool.name', toolName);
41
41
  span.setAttribute('ssh.profile', profileName);
42
42
  try {
43
43
  const conn = await resolveConn(profileName);
44
- const evaluation = await policy.evaluateWithOpa(command, conn.profile, toolName);
44
+ const evaluation = await policy.evaluateWithOpa(command, conn.profile, toolName, resource);
45
45
  span.setAttribute('policy.decision', evaluation.decision);
46
46
  span.setAttribute('command.class', evaluation.commandClass);
47
47
  span.setAttribute('command.binary', evaluation.binary);
@@ -167,7 +167,7 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
167
167
  effective = opts.wrap(effective);
168
168
  state.command = effective;
169
169
  }
170
- const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName);
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
173
  // A command that fell to `safe` because a reader's grammar rejected one
@@ -207,6 +207,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
207
207
  profile,
208
208
  extra,
209
209
  synthetic: true,
210
+ resource: { remotePath },
210
211
  preCheck: () => { sanitizeRemotePath(remotePath); },
211
212
  }, async (rt) => {
212
213
  const budget = rt.conn.profile.maxOutputBytes;
@@ -255,6 +256,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
255
256
  profile,
256
257
  extra,
257
258
  synthetic: true,
259
+ resource: { remotePath, remoteWrite: true },
258
260
  preCheck: () => { sanitizeRemotePath(remotePath); checkMode(mode); },
259
261
  }, async (rt) => {
260
262
  const maxBytes = rt.conn.profile.transferMaxBytes;
@@ -306,6 +308,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
306
308
  profile,
307
309
  extra,
308
310
  synthetic: true,
311
+ resource: { remotePath },
309
312
  preCheck: () => { sanitizeRemotePath(remotePath); },
310
313
  }, async (rt) => {
311
314
  // As on the upload side, and for the same reason: refined before the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.17.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",