@jadchene/mcp-ssh-service 1.1.1 â 1.3.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 +257 -85
- package/dist/config.js +3 -0
- package/dist/tools/definitions.js +39 -6
- package/dist/tools/handlers.js +230 -42
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,95 +1,267 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- `
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
## Configuration
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
English | [įŽäŊ䏿](./README_zh.md)
|
|
2
|
+
|
|
3
|
+
# đ mcp-ssh
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
[](https://modelcontextprotocol.io/)
|
|
8
|
+
|
|
9
|
+
A **production-grade** Model Context Protocol (MCP) server designed for secure, stateless SSH automation. This service empowers AI agents to manage remote infrastructure with **human-in-the-loop** safety and **semantic environment awareness**.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## đ Key Pillars
|
|
14
|
+
|
|
15
|
+
### đ Uncompromising Security
|
|
16
|
+
* **Two-Step Confirmation**: High-risk operations (writes, deletes, restarts) return a `confirmationId`. Nothing happens until a human approves the specific transaction.
|
|
17
|
+
* **Command Blacklist**: Real-time regex interception for catastrophic commands like `rm -rf /` or `mkfs`.
|
|
18
|
+
* **Command Whitelist**: Trusted final command strings can bypass manual confirmation by matching configured regex patterns. This applies to built-in high-risk tools and to `execute_batch` sub-commands.
|
|
19
|
+
* **Single-Command Enforcement**: `execute_command` rejects shell chaining, pipes, redirection, subshells, and multiline payloads at the server layer.
|
|
20
|
+
* **Server-Level Read-Only**: Lock specific servers to a non-destructive mode at the configuration level.
|
|
21
|
+
* **Restricted File Deletion**: Hardcoded prevention of accidental deletion of system-critical paths like `/etc` or `/usr`.
|
|
22
|
+
|
|
23
|
+
### đ§ AI-Native Design
|
|
24
|
+
* **Semantic Infrastructure Discovery**: AI can list servers and understand their purposes via natural language descriptions.
|
|
25
|
+
* **Working Directory Aliases**: Map complex paths to simple aliases like `app-root` with descriptive metadata.
|
|
26
|
+
* **Contextual Pre-checks**: Built-in tools to verify dependencies (Docker, Git) before execution.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## đ Quick Start
|
|
31
|
+
|
|
32
|
+
### Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Install globally via npm
|
|
36
|
+
npm install -g @jadchene/mcp-ssh-service
|
|
37
|
+
|
|
38
|
+
# Start the server with a config file
|
|
39
|
+
mcp-ssh-service --config ./config.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Source Setup
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/jadchene/mcp-ssh.git
|
|
46
|
+
cd mcp-ssh
|
|
47
|
+
npm install
|
|
48
|
+
npm run build
|
|
49
|
+
node dist/index.js --config ./config.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## đ§Š Skill Integration (Recommended)
|
|
55
|
+
|
|
56
|
+
For AI assistants (Codex / Gemini / similar agents), this repository includes an SSH MCP skill that significantly improves execution quality and safety consistency.
|
|
57
|
+
|
|
58
|
+
- Skill path: `skills/ssh-mcp/SKILL.md`
|
|
59
|
+
- Benefits:
|
|
60
|
+
- Enforces strict two-step confirmation for high-risk operations
|
|
61
|
+
- Prefers `execute_batch` for multi-step workflows and avoids risky command chaining
|
|
62
|
+
- Standardizes server discovery, dependency checks, and post-action verification
|
|
63
|
+
- Reduces accidental destructive operations and context-loss mistakes
|
|
64
|
+
|
|
65
|
+
When your agent supports skills, load this skill before using SSH MCP tools for best results.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## âī¸ Configuration Schema
|
|
70
|
+
|
|
71
|
+
### Global Settings
|
|
72
|
+
| Parameter | Type | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `logDir` | string | Directory for logs. Supports env vars like `${HOME}`. |
|
|
75
|
+
| `commandBlacklist` | string[] | Prohibited command regex patterns (e.g., `["^rm -rf"]`). |
|
|
76
|
+
| `commandWhitelist` | string[] | Trusted final-command regex patterns that can skip confirmation for high-risk tools and `execute_batch` sub-commands. |
|
|
77
|
+
| `defaultTimeout` | number | Command timeout in milliseconds (default: 60000). |
|
|
78
|
+
| `servers` | object | Dictionary of server configs where key is the `serverAlias`. |
|
|
79
|
+
|
|
80
|
+
### Server Object
|
|
81
|
+
| Parameter | Type | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `host` | string | Remote IP or hostname. Supports env vars. |
|
|
84
|
+
| `port` | number | SSH port (default: 22). |
|
|
85
|
+
| `username` | string | SSH login user. |
|
|
86
|
+
| `password` | string | SSH password. Use `${VAR}` for security. |
|
|
87
|
+
| `privateKeyPath` | string | Path to private key file. |
|
|
88
|
+
| `passphrase` | string | Passphrase for the private key. |
|
|
89
|
+
| `readOnly` | boolean | Disables all write/modify tools for this server. |
|
|
90
|
+
| `desc` | string | Server description shown in `list_servers`. |
|
|
91
|
+
| `strictHostKeyChecking` | boolean | Set to `false` to bypass host key verification. |
|
|
92
|
+
| `workingDirectories` | object | Semantic path mappings (Key: { path, desc }). |
|
|
93
|
+
| `proxyJump` | object | Optional jump host (recursive server config). |
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## âī¸ Configuration Example
|
|
72
98
|
|
|
73
99
|
```json
|
|
74
100
|
{
|
|
101
|
+
"logDir": "./logs",
|
|
102
|
+
"defaultTimeout": 60000,
|
|
103
|
+
"commandBlacklist": ["^apt-get upgrade", "curl.*\\|.*sh"],
|
|
104
|
+
"commandWhitelist": ["^systemctl status\\s+nginx$", "^docker ps$"],
|
|
75
105
|
"servers": {
|
|
76
|
-
"prod-web": {
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
"prod-web": {
|
|
107
|
+
"desc": "Primary API Cluster",
|
|
108
|
+
"host": "10.0.0.5",
|
|
109
|
+
"username": "deploy",
|
|
110
|
+
"privateKeyPath": "~/.ssh/id_rsa",
|
|
111
|
+
"passphrase": "${SSH_KEY_PWD}",
|
|
112
|
+
"workingDirectories": {
|
|
113
|
+
"logs": { "path": "/var/log/nginx", "desc": "Nginx access logs" }
|
|
114
|
+
},
|
|
115
|
+
"proxyJump": {
|
|
116
|
+
"host": "bastion.example.com",
|
|
117
|
+
"username": "jumpuser"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## MCP Client Configuration
|
|
127
|
+
|
|
128
|
+
The following examples show how to register this MCP server in common AI clients. Replace the config path with your own local file path. To keep the setup portable, the examples below intentionally avoid absolute paths.
|
|
129
|
+
|
|
130
|
+
### Codex
|
|
131
|
+
|
|
132
|
+
`~/.codex/config.toml`
|
|
133
|
+
|
|
134
|
+
```toml
|
|
135
|
+
[mcp_servers.ssh]
|
|
136
|
+
command = "mcp-ssh-service"
|
|
137
|
+
args = ["--config", "./config.json"]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Gemini CLI
|
|
141
|
+
|
|
142
|
+
`~/.gemini/settings.json`
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"mcpServers": {
|
|
147
|
+
"ssh": {
|
|
148
|
+
"type": "stdio",
|
|
149
|
+
"command": "mcp-ssh-service",
|
|
150
|
+
"args": [
|
|
151
|
+
"--config",
|
|
152
|
+
"./config.json"
|
|
153
|
+
]
|
|
84
154
|
}
|
|
85
155
|
}
|
|
86
156
|
}
|
|
87
157
|
```
|
|
88
158
|
|
|
89
|
-
|
|
159
|
+
### Claude Code
|
|
90
160
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
161
|
+
`~/.claude.json`
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"mcpServers": {
|
|
166
|
+
"ssh": {
|
|
167
|
+
"type": "stdio",
|
|
168
|
+
"command": "mcp-ssh-service",
|
|
169
|
+
"args": [
|
|
170
|
+
"--config",
|
|
171
|
+
"./config.json"
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## đ ī¸ Integrated Toolset (50 Tools)
|
|
181
|
+
|
|
182
|
+
### Discovery & Core (8)
|
|
183
|
+
* `list_servers`
|
|
184
|
+
* `ping_server`
|
|
185
|
+
* `list_working_directories`
|
|
186
|
+
* `check_dependencies`
|
|
187
|
+
* `get_system_info`
|
|
188
|
+
* `pwd`
|
|
189
|
+
* `cd`
|
|
190
|
+
* `execute_batch` [Auth Required if any sub-command is high-risk]
|
|
191
|
+
|
|
192
|
+
### Shell & Basic (2)
|
|
193
|
+
* `execute_command` [Auth Required, single command only]
|
|
194
|
+
* `echo`
|
|
195
|
+
|
|
196
|
+
### File Management (10)
|
|
197
|
+
* `upload_file` [Auth Required]
|
|
198
|
+
* `download_file`
|
|
199
|
+
* `ll`
|
|
200
|
+
* `cat`
|
|
201
|
+
* `tail`
|
|
202
|
+
* `grep`
|
|
203
|
+
* `edit_text_file` [Auth Required]
|
|
204
|
+
* `touch`
|
|
205
|
+
* `rm_safe` [Auth Required]
|
|
206
|
+
* `find`
|
|
207
|
+
|
|
208
|
+
### Git (2)
|
|
209
|
+
* `git_status`
|
|
210
|
+
* `git_pull` [Auth Required]
|
|
211
|
+
|
|
212
|
+
### Docker & Compose (17)
|
|
213
|
+
* `docker_compose_up` [Auth Required]
|
|
214
|
+
* `docker_compose_down` [Auth Required]
|
|
215
|
+
* `docker_compose_stop` [Auth Required]
|
|
216
|
+
* `docker_compose_logs`
|
|
217
|
+
* `docker_compose_restart` [Auth Required]
|
|
218
|
+
* `docker_ps`
|
|
219
|
+
* `docker_images`
|
|
220
|
+
* `docker_pull` [Auth Required]
|
|
221
|
+
* `docker_cp` [Auth Required]
|
|
222
|
+
* `docker_stop` [Auth Required]
|
|
223
|
+
* `docker_rm` [Auth Required]
|
|
224
|
+
* `docker_start` [Auth Required]
|
|
225
|
+
* `docker_rmi` [Auth Required]
|
|
226
|
+
* `docker_commit` [Auth Required]
|
|
227
|
+
* `docker_logs`
|
|
228
|
+
* `docker_load` [Auth Required]
|
|
229
|
+
* `docker_save` [Auth Required]
|
|
230
|
+
|
|
231
|
+
### Service & Network (7)
|
|
232
|
+
* `systemctl_status`
|
|
233
|
+
* `systemctl_restart` [Auth Required]
|
|
234
|
+
* `systemctl_start` [Auth Required]
|
|
235
|
+
* `systemctl_stop` [Auth Required]
|
|
236
|
+
* `ip_addr`
|
|
237
|
+
* `firewall_cmd` [Auth Required, structured actions only]
|
|
238
|
+
* `netstat` [uses `args: string[]`]
|
|
239
|
+
|
|
240
|
+
### Stats & Process (4)
|
|
241
|
+
* `nvidia_smi`
|
|
242
|
+
* `ps`
|
|
243
|
+
* `df_h`
|
|
244
|
+
* `du_sh`
|
|
245
|
+
|
|
246
|
+
Total: 50 tools.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## đ The Confirmation Workflow
|
|
251
|
+
|
|
252
|
+
1. **Request**: AI calls `execute_command({ command: 'systemctl restart nginx' })`.
|
|
253
|
+
2. **Intercept**: Server returns `status: "pending"` with a `confirmationId`.
|
|
254
|
+
3. **Human Input**: You review the action in your chat client and approve.
|
|
255
|
+
4. **Execution**: AI calls `execute_command` again with the `confirmationId` and `confirmExecution: true`.
|
|
256
|
+
5. **Verify**: Server ensures parameters match exactly and executes the SSH command.
|
|
257
|
+
|
|
258
|
+
If a high-risk tool's final command string matches `commandWhitelist`, the server skips the pending confirmation step and runs it directly. For `execute_batch`, only non-whitelisted high-risk sub-commands keep the batch in the confirmation flow.
|
|
259
|
+
|
|
260
|
+
`execute_command` is limited to one shell command segment. The server rejects chaining operators such as `&&`, `||`, `;`, pipes, redirection, subshell syntax, and multiline input. For built-in tools, user-provided parameters are shell-escaped before execution to reduce command injection risk.
|
|
261
|
+
|
|
262
|
+
`firewall_cmd` no longer accepts a free-form shell fragment. Use structured fields such as `action`, `port`, `zone`, `permanent`, and `listTarget`. `netstat` now accepts `args: string[]` so each option is validated as an individual token.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## đ License
|
|
267
|
+
Released under the [MIT License](./LICENSE).
|
package/dist/config.js
CHANGED
|
@@ -117,6 +117,9 @@ export class ConfigManager {
|
|
|
117
117
|
getGlobalBlacklist() {
|
|
118
118
|
return this.config.commandBlacklist || [];
|
|
119
119
|
}
|
|
120
|
+
getGlobalWhitelist() {
|
|
121
|
+
return this.config.commandWhitelist || [];
|
|
122
|
+
}
|
|
120
123
|
getDefaultTimeout() {
|
|
121
124
|
return this.config.defaultTimeout || 60000;
|
|
122
125
|
}
|
|
@@ -55,7 +55,7 @@ export const toolDefinitions = [
|
|
|
55
55
|
// --- Batch (Core) ---
|
|
56
56
|
{
|
|
57
57
|
name: 'execute_batch',
|
|
58
|
-
description: 'Workflow automation: Executes a sequence of multiple tools in a single persistent SSH session. REQUIRES CONFIRMATION
|
|
58
|
+
description: 'Workflow automation: Executes a sequence of multiple tools in a single persistent SSH session. REQUIRES CONFIRMATION when any high-risk sub-tool final command is not whitelisted.',
|
|
59
59
|
inputSchema: baseParams({
|
|
60
60
|
commands: {
|
|
61
61
|
type: 'array',
|
|
@@ -75,7 +75,7 @@ export const toolDefinitions = [
|
|
|
75
75
|
// --- Shell & Basic (Requirements) ---
|
|
76
76
|
{
|
|
77
77
|
name: 'execute_command',
|
|
78
|
-
description: '
|
|
78
|
+
description: 'Single-command execution: Runs exactly one shell command segment via SSH. Rejects chaining, pipes, redirection, subshell syntax, and multiline input. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
79
79
|
inputSchema: baseParams({
|
|
80
80
|
command: { type: 'string' },
|
|
81
81
|
...cwdParam,
|
|
@@ -115,6 +115,16 @@ export const toolDefinitions = [
|
|
|
115
115
|
description: 'File reading: Reads text file content.',
|
|
116
116
|
inputSchema: baseParams({ filePath: { type: 'string' }, ...grepParam }, ['filePath'])
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
name: 'tail',
|
|
120
|
+
description: 'Log inspection: Reads last N lines of a file.',
|
|
121
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, lines: { type: 'number' }, ...grepParam }, ['filePath'])
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'grep',
|
|
125
|
+
description: 'Pattern search: Search for a regex pattern in a file.',
|
|
126
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, pattern: { type: 'string' }, ignoreCase: { type: 'boolean' } }, ['filePath', 'pattern'])
|
|
127
|
+
},
|
|
118
128
|
{
|
|
119
129
|
name: 'edit_text_file',
|
|
120
130
|
description: 'File creation/overwrite: Completely replaces file content. REQUIRES CONFIRMATION.',
|
|
@@ -129,11 +139,27 @@ export const toolDefinitions = [
|
|
|
129
139
|
description: 'Timestamp/File creation: Updates access time or creates empty file.',
|
|
130
140
|
inputSchema: baseParams({ filePath: { type: 'string' } }, ['filePath'])
|
|
131
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'rm_safe',
|
|
144
|
+
description: 'File deletion: Removes file or directory. REQUIRES CONFIRMATION.',
|
|
145
|
+
inputSchema: baseParams({ path: { type: 'string' }, recursive: { type: 'boolean' }, ...confirmationParams }, ['path'])
|
|
146
|
+
},
|
|
132
147
|
{
|
|
133
148
|
name: 'find',
|
|
134
149
|
description: 'Search for files in a directory hierarchy.',
|
|
135
150
|
inputSchema: baseParams({ path: { type: 'string' }, name: { type: 'string' }, ...grepParam }, ['path'])
|
|
136
151
|
},
|
|
152
|
+
// --- Git ---
|
|
153
|
+
{
|
|
154
|
+
name: 'git_status',
|
|
155
|
+
description: 'Git status: Displays repository status.',
|
|
156
|
+
inputSchema: baseParams(cwdParam)
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'git_pull',
|
|
160
|
+
description: 'Git update: Pulls latest changes. REQUIRES CONFIRMATION.',
|
|
161
|
+
inputSchema: baseParams({ ...cwdParam, ...confirmationParams })
|
|
162
|
+
},
|
|
137
163
|
// --- Docker & Compose (Requirements) ---
|
|
138
164
|
{
|
|
139
165
|
name: 'docker_compose_up',
|
|
@@ -248,13 +274,20 @@ export const toolDefinitions = [
|
|
|
248
274
|
},
|
|
249
275
|
{
|
|
250
276
|
name: 'firewall_cmd',
|
|
251
|
-
description: '
|
|
252
|
-
inputSchema: baseParams({
|
|
277
|
+
description: 'Structured firewall control. Supports action=list|add-port|remove-port|reload with optional zone, permanent, and listTarget. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
278
|
+
inputSchema: baseParams({
|
|
279
|
+
action: { type: 'string', enum: ['list', 'add-port', 'remove-port', 'reload'] },
|
|
280
|
+
listTarget: { type: 'string', enum: ['ports', 'services', 'all'] },
|
|
281
|
+
port: { type: 'string' },
|
|
282
|
+
zone: { type: 'string' },
|
|
283
|
+
permanent: { type: 'boolean' },
|
|
284
|
+
...confirmationParams
|
|
285
|
+
}, ['action'])
|
|
253
286
|
},
|
|
254
287
|
{
|
|
255
288
|
name: 'netstat',
|
|
256
|
-
description: 'Monitor ports/connections.',
|
|
257
|
-
inputSchema: baseParams({ args: { type: 'string' }, ...grepParam })
|
|
289
|
+
description: 'Monitor ports/connections. Use args as an array of individual option tokens, for example ["-t", "-u", "-l", "-n"].',
|
|
290
|
+
inputSchema: baseParams({ args: { type: 'array', items: { type: 'string' } }, ...grepParam })
|
|
258
291
|
},
|
|
259
292
|
// --- Stats & Process (Requirements) ---
|
|
260
293
|
{
|
package/dist/tools/handlers.js
CHANGED
|
@@ -4,6 +4,8 @@ const WRITE_TOOLS = [
|
|
|
4
4
|
'execute_command',
|
|
5
5
|
'upload_file',
|
|
6
6
|
'edit_text_file',
|
|
7
|
+
'rm_safe',
|
|
8
|
+
'git_pull',
|
|
7
9
|
'docker_compose_up',
|
|
8
10
|
'docker_compose_down',
|
|
9
11
|
'docker_compose_stop',
|
|
@@ -36,6 +38,13 @@ export class ToolHandlers {
|
|
|
36
38
|
constructor(configManager) {
|
|
37
39
|
this.configManager = configManager;
|
|
38
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Build regex list from config patterns using case-insensitive matching to keep
|
|
43
|
+
* behavior aligned with the existing blacklist implementation.
|
|
44
|
+
*/
|
|
45
|
+
compileUserPatterns(patterns) {
|
|
46
|
+
return patterns.map((pattern) => new RegExp(pattern, 'i'));
|
|
47
|
+
}
|
|
39
48
|
getServerConfig(alias) {
|
|
40
49
|
const config = this.configManager.getServerConfig(alias);
|
|
41
50
|
if (!config) {
|
|
@@ -51,29 +60,159 @@ export class ToolHandlers {
|
|
|
51
60
|
}
|
|
52
61
|
return cwd;
|
|
53
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Escape arbitrary text as a single POSIX shell argument to avoid command
|
|
65
|
+
* injection through built-in tool parameters.
|
|
66
|
+
*/
|
|
67
|
+
shellEscape(value) {
|
|
68
|
+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* execute_command is intentionally limited to one command segment. Chaining,
|
|
72
|
+
* pipes, subshells, redirection, and multiline payloads must use safer tools
|
|
73
|
+
* or execute_batch instead.
|
|
74
|
+
*/
|
|
75
|
+
validateSingleCommand(command) {
|
|
76
|
+
this.ensureNoShellControl(command, 'execute_command only supports a single command without shell chaining, pipes, redirection, subshells, or multiline input.');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate free-form option fragments that intentionally allow spaces but must
|
|
80
|
+
* never introduce shell control syntax.
|
|
81
|
+
*/
|
|
82
|
+
validateShellFragment(value, fieldName) {
|
|
83
|
+
this.ensureNoShellControl(value, `${fieldName} contains forbidden shell control characters.`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Validate one shell token that is expected to remain a single argument.
|
|
87
|
+
*/
|
|
88
|
+
validateShellToken(value, fieldName) {
|
|
89
|
+
if (/\s/.test(value)) {
|
|
90
|
+
throw new Error(`${fieldName} must be a single token without spaces.`);
|
|
91
|
+
}
|
|
92
|
+
this.validateShellFragment(value, fieldName);
|
|
93
|
+
}
|
|
94
|
+
ensureNoShellControl(value, errorMessage) {
|
|
95
|
+
const forbiddenOperators = [/&&/, /\|\|/, /;/, /\|/, /\$\(/, /`/, />/, /</, /\r|\n/];
|
|
96
|
+
for (const pattern of forbiddenOperators) {
|
|
97
|
+
if (pattern.test(value)) {
|
|
98
|
+
throw new Error(errorMessage);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
54
102
|
checkBlacklist(command) {
|
|
55
|
-
const userBlacklist = this.configManager.getGlobalBlacklist();
|
|
56
|
-
const
|
|
57
|
-
for (const pattern of
|
|
103
|
+
const userBlacklist = this.compileUserPatterns(this.configManager.getGlobalBlacklist());
|
|
104
|
+
const normalizedCommand = this.stripQuotedLiterals(command);
|
|
105
|
+
for (const pattern of DEFAULT_BLACKLIST) {
|
|
106
|
+
if (pattern.test(normalizedCommand)) {
|
|
107
|
+
throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const pattern of userBlacklist) {
|
|
58
111
|
if (pattern.test(command)) {
|
|
59
112
|
throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
|
|
60
113
|
}
|
|
61
114
|
}
|
|
62
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Remove quoted literal payloads before evaluating built-in default blacklist
|
|
118
|
+
* rules so escaped user arguments do not look like executable shell syntax.
|
|
119
|
+
*/
|
|
120
|
+
stripQuotedLiterals(command) {
|
|
121
|
+
return command
|
|
122
|
+
.replace(/'[^']*'/g, "''")
|
|
123
|
+
.replace(/"([^"\\]|\\.)*"/g, '""');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Whitelisted execute_command payloads can bypass the confirmation flow, but
|
|
127
|
+
* they still must pass blacklist validation first.
|
|
128
|
+
*/
|
|
129
|
+
isCommandWhitelisted(command) {
|
|
130
|
+
const userWhitelist = this.compileUserPatterns(this.configManager.getGlobalWhitelist());
|
|
131
|
+
return userWhitelist.some((pattern) => pattern.test(command));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the exact shell command string that will be executed for command-based
|
|
135
|
+
* tools so that security rules operate on the same final text.
|
|
136
|
+
*/
|
|
137
|
+
getExecutableCommand(name, params) {
|
|
138
|
+
let command = this.getCommandForTool(name, params);
|
|
139
|
+
if (!command)
|
|
140
|
+
return '';
|
|
141
|
+
if (params.grep) {
|
|
142
|
+
this.validateShellFragment(params.grep, 'grep');
|
|
143
|
+
command += ` | grep -E ${this.shellEscape(params.grep)}`;
|
|
144
|
+
}
|
|
145
|
+
return command;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Determine whether the current tool invocation still needs confirmation after
|
|
149
|
+
* command whitelist rules are applied to the final executable command.
|
|
150
|
+
*/
|
|
151
|
+
requiresConfirmation(name, params) {
|
|
152
|
+
if (name !== 'execute_batch') {
|
|
153
|
+
if (!WRITE_TOOLS.includes(name))
|
|
154
|
+
return false;
|
|
155
|
+
const command = this.getExecutableCommand(name, params);
|
|
156
|
+
return command ? !this.isCommandWhitelisted(command) : true;
|
|
157
|
+
}
|
|
158
|
+
return params.commands?.some((cmd) => {
|
|
159
|
+
if (!WRITE_TOOLS.includes(cmd.name))
|
|
160
|
+
return false;
|
|
161
|
+
const command = this.getExecutableCommand(cmd.name, cmd.arguments);
|
|
162
|
+
return command ? !this.isCommandWhitelisted(command) : true;
|
|
163
|
+
}) ?? false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Determine whether a tool invocation is fundamentally a write action,
|
|
167
|
+
* regardless of whether whitelist rules later skip manual confirmation.
|
|
168
|
+
*/
|
|
169
|
+
isWriteToolCall(name, params) {
|
|
170
|
+
if (name !== 'execute_batch') {
|
|
171
|
+
return WRITE_TOOLS.includes(name);
|
|
172
|
+
}
|
|
173
|
+
return params.commands?.some((cmd) => WRITE_TOOLS.includes(cmd.name)) ?? false;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Apply blacklist validation to every command-bearing tool invocation before
|
|
177
|
+
* confirmation and execution.
|
|
178
|
+
*/
|
|
179
|
+
validateToolCommand(name, params) {
|
|
180
|
+
if (name === 'execute_command') {
|
|
181
|
+
this.validateSingleCommand(params.command);
|
|
182
|
+
}
|
|
183
|
+
if (name === 'netstat' && Array.isArray(params.args)) {
|
|
184
|
+
for (const [index, arg] of params.args.entries()) {
|
|
185
|
+
this.validateShellToken(arg, `netstat.args[${index}]`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const command = this.getExecutableCommand(name, params);
|
|
189
|
+
if (command) {
|
|
190
|
+
this.checkBlacklist(command);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
63
193
|
getCommandForTool(name, params) {
|
|
64
194
|
switch (name) {
|
|
65
195
|
case 'get_system_info': return 'echo "USER: $(whoami)"; echo "UPTIME: $(uptime)"; echo "KERNEL: $(uname -a)"; echo "MEMORY:"; free -m';
|
|
66
|
-
case 'check_dependencies': return `for cmd in ${params.commands.join(' ')}; do which $cmd || echo "$cmd not found"; done`;
|
|
196
|
+
case 'check_dependencies': return `for cmd in ${params.commands.map((cmd) => this.shellEscape(cmd)).join(' ')}; do which "$cmd" || echo "$cmd not found"; done`;
|
|
67
197
|
case 'pwd': return 'pwd';
|
|
68
|
-
case 'cd': return `cd ${params.path}`;
|
|
198
|
+
case 'cd': return `cd ${this.shellEscape(params.path)}`;
|
|
69
199
|
case 'll': return 'ls -l';
|
|
70
|
-
case 'cat': return `cat ${params.filePath}`;
|
|
200
|
+
case 'cat': return `cat ${this.shellEscape(params.filePath)}`;
|
|
201
|
+
case 'tail': return `tail -n ${params.lines || 50} ${this.shellEscape(params.filePath)}`;
|
|
202
|
+
case 'grep': return `grep ${params.ignoreCase ? '-inE' : '-nE'} ${this.shellEscape(params.pattern)} ${this.shellEscape(params.filePath)}`;
|
|
71
203
|
case 'edit_text_file':
|
|
72
204
|
const edB64 = Buffer.from(params.content).toString('base64');
|
|
73
|
-
return `
|
|
74
|
-
case 'touch': return `touch ${params.filePath}`;
|
|
75
|
-
case '
|
|
76
|
-
|
|
205
|
+
return `printf '%s' ${this.shellEscape(edB64)} | base64 -d > ${this.shellEscape(params.filePath)}`;
|
|
206
|
+
case 'touch': return `touch ${this.shellEscape(params.filePath)}`;
|
|
207
|
+
case 'rm_safe':
|
|
208
|
+
const restricted = ['/', '/etc', '/usr', '/bin', '/var', '/root', '/home'];
|
|
209
|
+
if (restricted.includes(params.path.trim()))
|
|
210
|
+
throw new Error(`RM_SAFE: Denied for restricted directory.`);
|
|
211
|
+
return `rm ${params.recursive ? '-rf' : '-f'} ${this.shellEscape(params.path)}`;
|
|
212
|
+
case 'echo': return `echo ${this.shellEscape(params.text)}`;
|
|
213
|
+
case 'find': return `find ${this.shellEscape(params.path)} -name ${this.shellEscape(params.name)}`;
|
|
214
|
+
case 'git_status': return 'git status';
|
|
215
|
+
case 'git_pull': return 'git pull --no-edit';
|
|
77
216
|
case 'execute_command': return params.command;
|
|
78
217
|
case 'docker_compose_up': return 'docker-compose up -d';
|
|
79
218
|
case 'docker_compose_down': return 'docker-compose down --remove-orphans';
|
|
@@ -82,30 +221,81 @@ export class ToolHandlers {
|
|
|
82
221
|
case 'docker_compose_restart': return 'docker-compose restart';
|
|
83
222
|
case 'docker_ps': return 'docker ps';
|
|
84
223
|
case 'docker_images': return 'docker images';
|
|
85
|
-
case 'docker_pull': return `docker pull ${params.image}`;
|
|
86
|
-
case 'docker_cp': return `docker cp ${params.source} ${params.destination}`;
|
|
87
|
-
case 'docker_stop': return `docker stop ${params.container}`;
|
|
88
|
-
case 'docker_rm': return `docker rm ${params.container}`;
|
|
89
|
-
case 'docker_start': return `docker start ${params.container}`;
|
|
90
|
-
case 'docker_rmi': return `docker rmi ${params.image}`;
|
|
91
|
-
case 'docker_commit': return `docker commit ${params.container} ${params.repository}`;
|
|
92
|
-
case 'docker_logs': return `docker logs -n ${params.lines || 100} ${params.container}`;
|
|
93
|
-
case 'docker_load': return `docker load -i ${params.path}`;
|
|
94
|
-
case 'docker_save': return `docker save -o ${params.path} ${params.image}`;
|
|
95
|
-
case 'systemctl_status': return `systemctl status ${params.service}`;
|
|
96
|
-
case 'systemctl_restart': return `systemctl restart ${params.service}`;
|
|
97
|
-
case 'systemctl_start': return `systemctl start ${params.service}`;
|
|
98
|
-
case 'systemctl_stop': return `systemctl stop ${params.service}`;
|
|
224
|
+
case 'docker_pull': return `docker pull ${this.shellEscape(params.image)}`;
|
|
225
|
+
case 'docker_cp': return `docker cp ${this.shellEscape(params.source)} ${this.shellEscape(params.destination)}`;
|
|
226
|
+
case 'docker_stop': return `docker stop ${this.shellEscape(params.container)}`;
|
|
227
|
+
case 'docker_rm': return `docker rm ${this.shellEscape(params.container)}`;
|
|
228
|
+
case 'docker_start': return `docker start ${this.shellEscape(params.container)}`;
|
|
229
|
+
case 'docker_rmi': return `docker rmi ${this.shellEscape(params.image)}`;
|
|
230
|
+
case 'docker_commit': return `docker commit ${this.shellEscape(params.container)} ${this.shellEscape(params.repository)}`;
|
|
231
|
+
case 'docker_logs': return `docker logs -n ${params.lines || 100} ${this.shellEscape(params.container)}`;
|
|
232
|
+
case 'docker_load': return `docker load -i ${this.shellEscape(params.path)}`;
|
|
233
|
+
case 'docker_save': return `docker save -o ${this.shellEscape(params.path)} ${this.shellEscape(params.image)}`;
|
|
234
|
+
case 'systemctl_status': return `systemctl status ${this.shellEscape(params.service)}`;
|
|
235
|
+
case 'systemctl_restart': return `systemctl restart ${this.shellEscape(params.service)}`;
|
|
236
|
+
case 'systemctl_start': return `systemctl start ${this.shellEscape(params.service)}`;
|
|
237
|
+
case 'systemctl_stop': return `systemctl stop ${this.shellEscape(params.service)}`;
|
|
99
238
|
case 'ip_addr': return 'ip addr';
|
|
100
|
-
case 'firewall_cmd':
|
|
101
|
-
|
|
239
|
+
case 'firewall_cmd':
|
|
240
|
+
return this.buildFirewallCommand(params);
|
|
241
|
+
case 'netstat':
|
|
242
|
+
return `netstat ${(params.args && params.args.length > 0) ? params.args.map((arg, index) => {
|
|
243
|
+
this.validateShellToken(arg, `netstat.args[${index}]`);
|
|
244
|
+
return arg;
|
|
245
|
+
}).join(' ') : '-tuln'}`;
|
|
102
246
|
case 'df_h': return 'df -h';
|
|
103
|
-
case 'du_sh': return `du -sh ${params.path}`;
|
|
247
|
+
case 'du_sh': return `du -sh ${this.shellEscape(params.path)}`;
|
|
104
248
|
case 'nvidia_smi': return 'nvidia-smi';
|
|
105
249
|
case 'ps': return 'ps aux';
|
|
106
250
|
default: return '';
|
|
107
251
|
}
|
|
108
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Build firewall-cmd from structured inputs so the service controls the final
|
|
255
|
+
* command shape instead of accepting a free-form shell fragment.
|
|
256
|
+
*/
|
|
257
|
+
buildFirewallCommand(params) {
|
|
258
|
+
const parts = ['firewall-cmd'];
|
|
259
|
+
if (params.zone) {
|
|
260
|
+
this.validateShellToken(params.zone, 'firewall_cmd.zone');
|
|
261
|
+
parts.push(`--zone=${params.zone}`);
|
|
262
|
+
}
|
|
263
|
+
if (params.permanent) {
|
|
264
|
+
parts.push('--permanent');
|
|
265
|
+
}
|
|
266
|
+
switch (params.action) {
|
|
267
|
+
case 'reload':
|
|
268
|
+
parts.push('--reload');
|
|
269
|
+
break;
|
|
270
|
+
case 'list': {
|
|
271
|
+
const listTarget = params.listTarget || 'ports';
|
|
272
|
+
const targetMap = {
|
|
273
|
+
ports: '--list-ports',
|
|
274
|
+
services: '--list-services',
|
|
275
|
+
all: '--list-all'
|
|
276
|
+
};
|
|
277
|
+
const targetFlag = targetMap[listTarget];
|
|
278
|
+
if (!targetFlag) {
|
|
279
|
+
throw new Error(`Unsupported firewall_cmd.listTarget: ${listTarget}`);
|
|
280
|
+
}
|
|
281
|
+
parts.push(targetFlag);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'add-port':
|
|
285
|
+
case 'remove-port': {
|
|
286
|
+
if (!params.port) {
|
|
287
|
+
throw new Error(`firewall_cmd action '${params.action}' requires 'port'.`);
|
|
288
|
+
}
|
|
289
|
+
this.validateShellToken(params.port, 'firewall_cmd.port');
|
|
290
|
+
const flag = params.action === 'add-port' ? '--add-port' : '--remove-port';
|
|
291
|
+
parts.push(`${flag}=${params.port}`);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
throw new Error(`Unsupported firewall_cmd action: ${params.action}`);
|
|
296
|
+
}
|
|
297
|
+
return parts.join(' ');
|
|
298
|
+
}
|
|
109
299
|
async handleTool(name, args) {
|
|
110
300
|
if (name === 'list_servers') {
|
|
111
301
|
const servers = this.configManager.getAllServers();
|
|
@@ -127,11 +317,19 @@ export class ToolHandlers {
|
|
|
127
317
|
return `Connection failed for server '${serverAlias}': ${err.message}`;
|
|
128
318
|
}
|
|
129
319
|
}
|
|
320
|
+
this.validateToolCommand(name, params);
|
|
321
|
+
if (name === 'execute_batch') {
|
|
322
|
+
for (const cmd of params.commands || []) {
|
|
323
|
+
this.validateToolCommand(cmd.name, cmd.arguments);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
130
326
|
// --- Confirmation Logic ---
|
|
131
|
-
const
|
|
327
|
+
const isWriteToolCall = this.isWriteToolCall(name, params);
|
|
328
|
+
const isWriteAction = this.requiresConfirmation(name, params);
|
|
329
|
+
if (isWriteToolCall && srv.readOnly) {
|
|
330
|
+
throw new Error(`Server '${serverAlias}' is read-only.`);
|
|
331
|
+
}
|
|
132
332
|
if (isWriteAction) {
|
|
133
|
-
if (srv.readOnly)
|
|
134
|
-
throw new Error(`Server '${serverAlias}' is read-only.`);
|
|
135
333
|
if (confirmationId && confirmExecution === true) {
|
|
136
334
|
const isValid = confirmationManager.validateAndPop(confirmationId, name, serverAlias, args);
|
|
137
335
|
if (!isValid)
|
|
@@ -152,10 +350,6 @@ export class ToolHandlers {
|
|
|
152
350
|
const commands = params.commands;
|
|
153
351
|
let results = [];
|
|
154
352
|
let currentBatchCwd = this.resolveCwd(srv, params.cwd);
|
|
155
|
-
for (const cmd of commands) {
|
|
156
|
-
if (cmd.name === 'execute_command')
|
|
157
|
-
this.checkBlacklist(cmd.arguments.command);
|
|
158
|
-
}
|
|
159
353
|
return await SSHClient.runSession(srv, async (conn) => {
|
|
160
354
|
for (const cmd of commands) {
|
|
161
355
|
if (cmd.name === 'cd') {
|
|
@@ -163,21 +357,17 @@ export class ToolHandlers {
|
|
|
163
357
|
results.push(`Directory changed to: ${currentBatchCwd}`);
|
|
164
358
|
continue;
|
|
165
359
|
}
|
|
166
|
-
let cmdStr = this.
|
|
360
|
+
let cmdStr = this.getExecutableCommand(cmd.name, cmd.arguments);
|
|
167
361
|
if (!cmdStr) {
|
|
168
362
|
results.push(`[${cmd.name}] Error: Not supported in batch.`);
|
|
169
363
|
continue;
|
|
170
364
|
}
|
|
171
|
-
if (cmd.arguments.grep)
|
|
172
|
-
cmdStr += ` | grep -E "${cmd.arguments.grep.replace(/"/g, '\\"')}"`;
|
|
173
365
|
const res = await SSHClient.executeOnConn(conn, cmdStr, currentBatchCwd, timeout);
|
|
174
366
|
results.push(`[${cmd.name}]\n${res.stdout}${res.stderr ? '\n[STDERR]\n' + res.stderr : ''}`);
|
|
175
367
|
}
|
|
176
368
|
return results.join('\n\n---\n\n');
|
|
177
369
|
});
|
|
178
370
|
}
|
|
179
|
-
if (name === 'execute_command')
|
|
180
|
-
this.checkBlacklist(params.command);
|
|
181
371
|
const cwd = this.resolveCwd(srv, params.cwd);
|
|
182
372
|
if (name === 'list_working_directories') {
|
|
183
373
|
if (!srv.workingDirectories || Object.keys(srv.workingDirectories).length === 0) {
|
|
@@ -195,10 +385,8 @@ export class ToolHandlers {
|
|
|
195
385
|
await SSHClient.downloadFile(srv, params.remotePath, params.localPath);
|
|
196
386
|
return `Successfully downloaded ${params.remotePath} to ${params.localPath}`;
|
|
197
387
|
}
|
|
198
|
-
let commandToRun = this.
|
|
388
|
+
let commandToRun = this.getExecutableCommand(name, params);
|
|
199
389
|
if (commandToRun) {
|
|
200
|
-
if (params.grep)
|
|
201
|
-
commandToRun += ` | grep -E "${params.grep.replace(/"/g, '\\"')}"`;
|
|
202
390
|
const res = await SSHClient.executeCommand(srv, commandToRun, cwd, timeout);
|
|
203
391
|
let out = res.stdout;
|
|
204
392
|
if (res.stderr)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jadchene/mcp-ssh-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A production-ready, highly secure SSH MCP server featuring stateless connections, two-step operation confirmation, and comprehensive DevOps tool integration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "npm run build && node --test ./tests/handlers.test.mjs",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"watch": "tsc -w",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
18
19
|
"start": "node dist/index.js"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|