ssh-mcp 2.19.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,6 +593,14 @@ 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
606
  writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
@@ -2428,9 +2428,10 @@ function classifyCommandInDialect(command, depth, dialect) {
2428
2428
  if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
2429
2429
  highest = parsed;
2430
2430
  }
2431
- // `readOnlyRejection` only ever describes the outer command's own grammar
2432
- // rejection, and only means anything when that is also what decided the
2433
- // 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
2434
2435
  // condition: the synthetic floor and the nested-command loop above only
2435
2436
  // ever replace `highest` with something of strictly higher rank than
2436
2437
  // `outer`'s, so if either one fired, `highest` is no longer `outer` and the
@@ -2472,8 +2473,20 @@ function classifyOuter(trimmed, dialect) {
2472
2473
  const twoWordPrefix = words.slice(0, 2).join(' ');
2473
2474
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
2474
2475
  || READ_ONLY_SYNTHETIC.has(binary)) {
2475
- if (SHELL_CONTROL_CHARS.test(trimmed)) {
2476
- 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
+ };
2477
2490
  }
2478
2491
  // The SFTP verbs are synthesised by the tool layer, never typed by a caller,
2479
2492
  // so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
@@ -2481,11 +2494,6 @@ function classifyOuter(trimmed, dialect) {
2481
2494
  if (READ_ONLY_SYNTHETIC.has(binary)) {
2482
2495
  return { binary, fullCommand, class: 'read-only' };
2483
2496
  }
2484
- // Whichever key matched decides both which entry answers and how many
2485
- // leading words are the command name rather than an argument: a two-word
2486
- // entry's grammar reads words[2..], a one-word entry's reads words[1..].
2487
- const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
2488
- const readerName = matchedTwoWord ? twoWordPrefix : binary;
2489
2497
  const entry = READERS[readerName];
2490
2498
  const argWords = words.slice(matchedTwoWord ? 2 : 1);
2491
2499
  // An unquoted glob word is not a word this grammar can vouch for: the
@@ -2501,7 +2509,7 @@ function classifyOuter(trimmed, dialect) {
2501
2509
  binary,
2502
2510
  fullCommand,
2503
2511
  class: 'safe',
2504
- readOnlyRejection: { binary: readerName, word: globWord },
2512
+ readOnlyRejection: { binary: readerName, word: globWord, cause: 'grammar' },
2505
2513
  };
2506
2514
  }
2507
2515
  }
@@ -2511,7 +2519,7 @@ function classifyOuter(trimmed, dialect) {
2511
2519
  binary,
2512
2520
  fullCommand,
2513
2521
  class: 'safe',
2514
- readOnlyRejection: { binary: readerName, word: match.word },
2522
+ readOnlyRejection: { binary: readerName, word: match.word, cause: 'grammar' },
2515
2523
  };
2516
2524
  }
2517
2525
  return { binary, fullCommand, class: 'read-only' };
@@ -2519,17 +2527,40 @@ function classifyOuter(trimmed, dialect) {
2519
2527
  return { binary, fullCommand, class: 'safe' };
2520
2528
  }
2521
2529
  /**
2522
- * The sentence a refusal appends when it carries a `readOnlyRejection`, worded
2523
- * so the reason names the binary that would have qualified and the argv word
2524
- * that cost it: "is not accepted there" rather than "is not one of them",
2525
- * because most rejections are operand rules (`hostname NAME`, `sort /data`) or
2526
- * a listed value flag missing its value, where "is not one of them" would be
2527
- * false — it may be a perfectly good flag name, just not one bare or in this
2528
- * position. One function so the engine's role-binding denial and
2529
- * `read-command`'s `enforceClass` refusal never drift into two spellings of
2530
- * 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.
2531
2544
  */
2532
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
+ }
2533
2564
  // The rejected word can be the empty string — a quoted empty operand
2534
2565
  // (`hostname ""`) — and two bare backticks would render as a blank where
2535
2566
  // the word that cost the class should be. `""` is the shell's own spelling
@@ -453,9 +453,9 @@ export class PolicyEngine {
453
453
  }
454
454
  }
455
455
  // A `safe` command refused here is often not a hard "no" — it is one
456
- // argv word away from `read-only`, and without this the reader concludes
457
- // the binary is forbidden outright and never learns which spelling would
458
- // 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.
459
459
  if (parsed.readOnlyRejection) {
460
460
  reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
461
461
  }
@@ -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
  : '';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.19.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",