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.
package/build/guard/sanitizer.js
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
|
|
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.
|
|
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
|
}
|
package/build/ssh/connection.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|