ssh-mcp 2.8.2 → 2.8.3

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.
@@ -1,13 +1,41 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
- const CONTROL_CHARS = /[\r\n\u2028\u2029\x00]/g;
2
+ /** Every character a shell would read as the end of one command and the start of another. */
3
+ const LINE_BREAKS = /[\r\n\u2028\u2029]/;
4
+ const NUL = /\u0000/;
5
+ /**
6
+ * Validate a caller-supplied command.
7
+ *
8
+ * A line break is refused, not removed. The constraint itself is not negotiable
9
+ * — a newline inside `command` would let a second command ride along past a
10
+ * classifier that parsed only the first, which is #44 — but an earlier version
11
+ * enforced it by replacing the break with a space, and *that* is the part worth
12
+ * changing. The caller got no error and a different command than it asked for:
13
+ * two lines joined, so `ls\necho x` ran `ls echo x`; or a `#` comment in a
14
+ * `python3 -c` body pulled onto the same line, commenting out everything after
15
+ * it. Sometimes that raises. Sometimes it runs and quietly does half the work,
16
+ * which is the failure mode worth removing (#198).
17
+ *
18
+ * Trailing and leading breaks are trimmed rather than refused. A client that
19
+ * appends a newline works today, refusing it would break that for no gain, and
20
+ * a break at either end cannot join two commands.
21
+ */
3
22
  export function sanitizeCommand(command, maxChars) {
4
23
  if (typeof command !== 'string') {
5
24
  throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
6
25
  }
7
- const cleaned = command.replace(CONTROL_CHARS, ' ').trim();
26
+ const cleaned = command.trim();
8
27
  if (!cleaned) {
9
28
  throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
10
29
  }
30
+ if (LINE_BREAKS.test(cleaned)) {
31
+ throw new McpError(ErrorCode.InvalidParams, 'Command cannot contain a line break: a second line would run past the classifier ' +
32
+ 'that only saw the first. It is refused rather than silently joined into one line, ' +
33
+ 'which is what happened before and changed what ran without saying so. ' +
34
+ 'To run a multi-line script, upload it with sftp-upload and execute it by path.');
35
+ }
36
+ if (NUL.test(cleaned)) {
37
+ throw new McpError(ErrorCode.InvalidParams, 'Command cannot contain a null byte');
38
+ }
11
39
  if (Number.isFinite(maxChars) && cleaned.length > maxChars) {
12
40
  throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${maxChars} characters)`);
13
41
  }
@@ -47,6 +47,8 @@ export class SSHConnection {
47
47
  sessions;
48
48
  activeChannels = 0;
49
49
  connecting = null;
50
+ /** Which connect() call currently owns `connecting`; see connect(). */
51
+ connectAttempt = null;
50
52
  connected = false;
51
53
  connectedAt = null;
52
54
  lastActivity = new Date();
@@ -94,6 +96,22 @@ export class SSHConnection {
94
96
  return this.connecting;
95
97
  }
96
98
  async connect() {
99
+ // Identity for this attempt, so "does this attempt still own `connecting`?"
100
+ // can be asked without going through `this.client`.
101
+ //
102
+ // Those are two different questions and conflating them is #197: settle()
103
+ // used to clear `connecting` only `if (isCurrent())`, and the disconnect
104
+ // handler nulls `this.client` *before* it settles. `isCurrent()` was
105
+ // therefore already false, `connecting` kept the rejected promise, and
106
+ // `ensureConnected()` replayed it for the life of the process — every later
107
+ // call on the profile failing instantly with a stale message, and no way
108
+ // back even once the server recovered. Restarting the server was the only
109
+ // exit, which is exactly how it was reported.
110
+ //
111
+ // Reordering the two statements would also have worked and would have left
112
+ // the same trap for the next edit; the coupling is what had to go.
113
+ const attempt = Symbol('connect');
114
+ this.connectAttempt = attempt;
97
115
  return new Promise((resolve, reject) => {
98
116
  // Every handler below acts on `client`, not `this.client`, and only
99
117
  // mutates shared state while it is still the current client. A late event
@@ -108,7 +126,9 @@ export class SSHConnection {
108
126
  return;
109
127
  settled = true;
110
128
  clearTimeout(timeoutId);
111
- if (isCurrent())
129
+ // Unconditional for *this* attempt: a superseded one leaves the newer
130
+ // attempt's promise alone, and nothing else can strand it.
131
+ if (this.connectAttempt === attempt)
112
132
  this.connecting = null;
113
133
  fn();
114
134
  };
@@ -5,9 +5,9 @@ export const TOOL_DESCRIPTIONS = {
5
5
  'open-session': 'Open a named session on a remote host. Use type="interactive" for stateful shell (CWD/env persists between commands) or type="background" for long-running processes.',
6
6
  'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
7
7
  'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
8
- 'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
9
- 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal.',
10
- 'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list).',
8
+ 'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
9
+ 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
10
+ 'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list). Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
11
11
  'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
13
  'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.8.2",
4
+ "version": "2.8.3",
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",