@jadchene/mcp-ssh-service 1.2.0 → 1.4.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 +149 -39
- package/dist/config.js +3 -0
- package/dist/tools/definitions.js +158 -6
- package/dist/tools/handlers.js +329 -46
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -13,10 +13,12 @@ A **production-grade** Model Context Protocol (MCP) server designed for secure,
|
|
|
13
13
|
## 🌟 Key Pillars
|
|
14
14
|
|
|
15
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
|
-
* **
|
|
19
|
-
* **
|
|
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`.
|
|
20
22
|
|
|
21
23
|
### 🧠 AI-Native Design
|
|
22
24
|
* **Semantic Infrastructure Discovery**: AI can list servers and understand their purposes via natural language descriptions.
|
|
@@ -44,20 +46,36 @@ git clone https://github.com/jadchene/mcp-ssh.git
|
|
|
44
46
|
cd mcp-ssh
|
|
45
47
|
npm install
|
|
46
48
|
npm run build
|
|
47
|
-
node dist/index.js --config ./config.json
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
##
|
|
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
|
|
53
70
|
|
|
54
71
|
### Global Settings
|
|
55
72
|
| Parameter | Type | Description |
|
|
56
73
|
| --- | --- | --- |
|
|
57
|
-
| `logDir` | string | Directory for logs. Supports env vars like `${HOME}`. |
|
|
58
|
-
| `commandBlacklist` | string[] | Prohibited command regex patterns (e.g., `["^rm -rf"]`). |
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
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`. |
|
|
61
79
|
|
|
62
80
|
### Server Object
|
|
63
81
|
| Parameter | Type | Description |
|
|
@@ -76,14 +94,15 @@ node dist/index.js --config ./config.json
|
|
|
76
94
|
|
|
77
95
|
---
|
|
78
96
|
|
|
79
|
-
## ⚙️ Configuration Example
|
|
80
|
-
|
|
81
|
-
```json
|
|
82
|
-
{
|
|
83
|
-
"logDir": "./logs",
|
|
84
|
-
"defaultTimeout": 60000,
|
|
85
|
-
"commandBlacklist": ["^apt-get upgrade", "curl.*\\|.*sh"],
|
|
86
|
-
"
|
|
97
|
+
## ⚙️ Configuration Example
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"logDir": "./logs",
|
|
102
|
+
"defaultTimeout": 60000,
|
|
103
|
+
"commandBlacklist": ["^apt-get upgrade", "curl.*\\|.*sh"],
|
|
104
|
+
"commandWhitelist": ["^systemctl status\\s+nginx$", "^docker ps$"],
|
|
105
|
+
"servers": {
|
|
87
106
|
"prod-web": {
|
|
88
107
|
"desc": "Primary API Cluster",
|
|
89
108
|
"host": "10.0.0.5",
|
|
@@ -99,12 +118,66 @@ node dist/index.js --config ./config.json
|
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
}
|
|
102
|
-
}
|
|
103
|
-
```
|
|
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
|
+
]
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Claude Code
|
|
160
|
+
|
|
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
|
+
```
|
|
104
177
|
|
|
105
178
|
---
|
|
106
179
|
|
|
107
|
-
## 🛠️ Integrated Toolset (
|
|
180
|
+
## 🛠️ Integrated Toolset (79 Tools)
|
|
108
181
|
|
|
109
182
|
### Discovery & Core (8)
|
|
110
183
|
* `list_servers`
|
|
@@ -117,10 +190,10 @@ node dist/index.js --config ./config.json
|
|
|
117
190
|
* `execute_batch` [Auth Required if any sub-command is high-risk]
|
|
118
191
|
|
|
119
192
|
### Shell & Basic (2)
|
|
120
|
-
* `execute_command` [Auth Required]
|
|
193
|
+
* `execute_command` [Auth Required, single command only]
|
|
121
194
|
* `echo`
|
|
122
195
|
|
|
123
|
-
### File Management (
|
|
196
|
+
### File Management (15)
|
|
124
197
|
* `upload_file` [Auth Required]
|
|
125
198
|
* `download_file`
|
|
126
199
|
* `ll`
|
|
@@ -129,14 +202,23 @@ node dist/index.js --config ./config.json
|
|
|
129
202
|
* `grep`
|
|
130
203
|
* `edit_text_file` [Auth Required]
|
|
131
204
|
* `touch`
|
|
205
|
+
* `mkdir` [Auth Required]
|
|
206
|
+
* `mv` [Auth Required]
|
|
207
|
+
* `cp` [Auth Required]
|
|
208
|
+
* `append_text_file` [Auth Required]
|
|
209
|
+
* `replace_in_file` [Auth Required]
|
|
132
210
|
* `rm_safe` [Auth Required]
|
|
133
211
|
* `find`
|
|
134
212
|
|
|
135
|
-
### Git (
|
|
213
|
+
### Git (6)
|
|
136
214
|
* `git_status`
|
|
215
|
+
* `git_fetch` [Auth Required]
|
|
137
216
|
* `git_pull` [Auth Required]
|
|
217
|
+
* `git_switch` [Auth Required]
|
|
218
|
+
* `git_branch`
|
|
219
|
+
* `git_log`
|
|
138
220
|
|
|
139
|
-
### Docker & Compose (
|
|
221
|
+
### Docker & Compose (21)
|
|
140
222
|
* `docker_compose_up` [Auth Required]
|
|
141
223
|
* `docker_compose_down` [Auth Required]
|
|
142
224
|
* `docker_compose_stop` [Auth Required]
|
|
@@ -144,43 +226,71 @@ node dist/index.js --config ./config.json
|
|
|
144
226
|
* `docker_compose_restart` [Auth Required]
|
|
145
227
|
* `docker_ps`
|
|
146
228
|
* `docker_images`
|
|
229
|
+
* `docker_exec` [Auth Required]
|
|
230
|
+
* `docker_inspect`
|
|
231
|
+
* `docker_stats`
|
|
147
232
|
* `docker_pull` [Auth Required]
|
|
148
233
|
* `docker_cp` [Auth Required]
|
|
149
234
|
* `docker_stop` [Auth Required]
|
|
150
235
|
* `docker_rm` [Auth Required]
|
|
151
236
|
* `docker_start` [Auth Required]
|
|
237
|
+
* `docker_restart` [Auth Required]
|
|
152
238
|
* `docker_rmi` [Auth Required]
|
|
153
239
|
* `docker_commit` [Auth Required]
|
|
154
240
|
* `docker_logs`
|
|
155
241
|
* `docker_load` [Auth Required]
|
|
156
242
|
* `docker_save` [Auth Required]
|
|
157
243
|
|
|
158
|
-
### Service & Network (
|
|
244
|
+
### Service & Network (14)
|
|
159
245
|
* `systemctl_status`
|
|
160
246
|
* `systemctl_restart` [Auth Required]
|
|
161
247
|
* `systemctl_start` [Auth Required]
|
|
162
248
|
* `systemctl_stop` [Auth Required]
|
|
163
249
|
* `ip_addr`
|
|
164
|
-
* `
|
|
165
|
-
* `
|
|
250
|
+
* `journalctl`
|
|
251
|
+
* `firewall_cmd` [Auth Required, structured actions only]
|
|
252
|
+
* `netstat` [uses `args: string[]`]
|
|
253
|
+
* `ss` [uses `args: string[]`]
|
|
254
|
+
* `ping_host`
|
|
255
|
+
* `traceroute`
|
|
256
|
+
* `nslookup`
|
|
257
|
+
* `dig`
|
|
258
|
+
* `curl_http` [Auth Required]
|
|
166
259
|
|
|
167
|
-
### Stats & Process (
|
|
260
|
+
### Stats & Process (13)
|
|
168
261
|
* `nvidia_smi`
|
|
169
262
|
* `ps`
|
|
263
|
+
* `pgrep`
|
|
264
|
+
* `kill_process` [Auth Required]
|
|
170
265
|
* `df_h`
|
|
171
266
|
* `du_sh`
|
|
267
|
+
* `chmod` [Auth Required]
|
|
268
|
+
* `chown` [Auth Required]
|
|
269
|
+
* `ln` [Auth Required]
|
|
270
|
+
* `tar_create` [Auth Required]
|
|
271
|
+
* `tar_extract` [Auth Required]
|
|
272
|
+
* `zip` [Auth Required]
|
|
273
|
+
* `unzip` [Auth Required]
|
|
172
274
|
|
|
173
|
-
Total:
|
|
275
|
+
Total: 79 tools.
|
|
174
276
|
|
|
175
277
|
---
|
|
176
278
|
|
|
177
|
-
## 🔐 The Confirmation Workflow
|
|
178
|
-
|
|
279
|
+
## 🔐 The Confirmation Workflow
|
|
280
|
+
|
|
179
281
|
1. **Request**: AI calls `execute_command({ command: 'systemctl restart nginx' })`.
|
|
180
|
-
2. **Intercept**: Server returns `status: "pending"` with a `confirmationId`.
|
|
181
|
-
3. **Human Input**: You review the action in your chat client and approve.
|
|
282
|
+
2. **Intercept**: Server returns `status: "pending"` with a `confirmationId`.
|
|
283
|
+
3. **Human Input**: You review the action in your chat client and approve.
|
|
182
284
|
4. **Execution**: AI calls `execute_command` again with the `confirmationId` and `confirmExecution: true`.
|
|
183
|
-
5. **Verify**: Server ensures parameters match exactly and executes the SSH command.
|
|
285
|
+
5. **Verify**: Server ensures parameters match exactly and executes the SSH command.
|
|
286
|
+
|
|
287
|
+
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.
|
|
288
|
+
|
|
289
|
+
`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.
|
|
290
|
+
|
|
291
|
+
`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.
|
|
292
|
+
|
|
293
|
+
Use `mkdir` for directory creation instead of `execute_command "mkdir ..."`. Set `parents: true` when you need `mkdir -p` behavior.
|
|
184
294
|
|
|
185
295
|
---
|
|
186
296
|
|
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,
|
|
@@ -139,6 +139,31 @@ export const toolDefinitions = [
|
|
|
139
139
|
description: 'Timestamp/File creation: Updates access time or creates empty file.',
|
|
140
140
|
inputSchema: baseParams({ filePath: { type: 'string' } }, ['filePath'])
|
|
141
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'mkdir',
|
|
144
|
+
description: 'Directory creation: Creates a directory. Set parents=true for mkdir -p behavior. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
145
|
+
inputSchema: baseParams({ path: { type: 'string' }, parents: { type: 'boolean' }, ...confirmationParams }, ['path'])
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'mv',
|
|
149
|
+
description: 'Move or rename a file or directory. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
150
|
+
inputSchema: baseParams({ source: { type: 'string' }, destination: { type: 'string' }, force: { type: 'boolean' }, ...confirmationParams }, ['source', 'destination'])
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'cp',
|
|
154
|
+
description: 'Copy a file or directory. Set recursive=true for directories. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
155
|
+
inputSchema: baseParams({ source: { type: 'string' }, destination: { type: 'string' }, recursive: { type: 'boolean' }, preserve: { type: 'boolean' }, ...confirmationParams }, ['source', 'destination'])
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'append_text_file',
|
|
159
|
+
description: 'Append text to a file, creating it if needed. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
160
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, content: { type: 'string' }, ...confirmationParams }, ['filePath', 'content'])
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'replace_in_file',
|
|
164
|
+
description: 'Replace literal text inside a file. Set replaceAll=false to replace only the first occurrence. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
165
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, search: { type: 'string' }, replace: { type: 'string' }, replaceAll: { type: 'boolean' }, ...confirmationParams }, ['filePath', 'search', 'replace'])
|
|
166
|
+
},
|
|
142
167
|
{
|
|
143
168
|
name: 'rm_safe',
|
|
144
169
|
description: 'File deletion: Removes file or directory. REQUIRES CONFIRMATION.',
|
|
@@ -155,11 +180,31 @@ export const toolDefinitions = [
|
|
|
155
180
|
description: 'Git status: Displays repository status.',
|
|
156
181
|
inputSchema: baseParams(cwdParam)
|
|
157
182
|
},
|
|
183
|
+
{
|
|
184
|
+
name: 'git_fetch',
|
|
185
|
+
description: 'Git fetch: Updates remote tracking refs. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
186
|
+
inputSchema: baseParams({ ...cwdParam, all: { type: 'boolean' }, prune: { type: 'boolean' }, ...confirmationParams })
|
|
187
|
+
},
|
|
158
188
|
{
|
|
159
189
|
name: 'git_pull',
|
|
160
190
|
description: 'Git update: Pulls latest changes. REQUIRES CONFIRMATION.',
|
|
161
191
|
inputSchema: baseParams({ ...cwdParam, ...confirmationParams })
|
|
162
192
|
},
|
|
193
|
+
{
|
|
194
|
+
name: 'git_switch',
|
|
195
|
+
description: 'Git switch: Switches branches, or creates one with create=true. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
196
|
+
inputSchema: baseParams({ ...cwdParam, branch: { type: 'string' }, create: { type: 'boolean' }, startPoint: { type: 'string' }, ...confirmationParams }, ['branch'])
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'git_branch',
|
|
200
|
+
description: 'Git branch: Lists local or all branches.',
|
|
201
|
+
inputSchema: baseParams({ ...cwdParam, all: { type: 'boolean' }, verbose: { type: 'boolean' } })
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'git_log',
|
|
205
|
+
description: 'Git log: Shows recent commit history.',
|
|
206
|
+
inputSchema: baseParams({ ...cwdParam, maxCount: { type: 'number' }, oneline: { type: 'boolean' }, path: { type: 'string' } })
|
|
207
|
+
},
|
|
163
208
|
// --- Docker & Compose (Requirements) ---
|
|
164
209
|
{
|
|
165
210
|
name: 'docker_compose_up',
|
|
@@ -196,6 +241,21 @@ export const toolDefinitions = [
|
|
|
196
241
|
description: 'List docker images.',
|
|
197
242
|
inputSchema: baseParams(grepParam)
|
|
198
243
|
},
|
|
244
|
+
{
|
|
245
|
+
name: 'docker_exec',
|
|
246
|
+
description: 'Run one process inside a running container without shell expansion. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
247
|
+
inputSchema: baseParams({ container: { type: 'string' }, command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, user: { type: 'string' }, workdir: { type: 'string' }, ...confirmationParams }, ['container', 'command'])
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'docker_inspect',
|
|
251
|
+
description: 'Inspect a container, image, volume, or network.',
|
|
252
|
+
inputSchema: baseParams({ target: { type: 'string' }, format: { type: 'string' } }, ['target'])
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'docker_stats',
|
|
256
|
+
description: 'Show container resource usage.',
|
|
257
|
+
inputSchema: baseParams({ container: { type: 'string' }, noStream: { type: 'boolean' } })
|
|
258
|
+
},
|
|
199
259
|
{
|
|
200
260
|
name: 'docker_pull',
|
|
201
261
|
description: 'Pull an image from a registry. REQUIRES CONFIRMATION.',
|
|
@@ -221,6 +281,11 @@ export const toolDefinitions = [
|
|
|
221
281
|
description: 'Start one or more stopped containers. REQUIRES CONFIRMATION.',
|
|
222
282
|
inputSchema: baseParams({ container: { type: 'string' }, ...confirmationParams }, ['container'])
|
|
223
283
|
},
|
|
284
|
+
{
|
|
285
|
+
name: 'docker_restart',
|
|
286
|
+
description: 'Restart one or more running containers. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
287
|
+
inputSchema: baseParams({ container: { type: 'string' }, ...confirmationParams }, ['container'])
|
|
288
|
+
},
|
|
224
289
|
{
|
|
225
290
|
name: 'docker_rmi',
|
|
226
291
|
description: 'Remove one or more images. REQUIRES CONFIRMATION.',
|
|
@@ -272,15 +337,57 @@ export const toolDefinitions = [
|
|
|
272
337
|
description: 'Show network interface info.',
|
|
273
338
|
inputSchema: baseParams(grepParam)
|
|
274
339
|
},
|
|
340
|
+
{
|
|
341
|
+
name: 'journalctl',
|
|
342
|
+
description: 'Read systemd journal logs with optional unit, since, and priority filters.',
|
|
343
|
+
inputSchema: baseParams({ unit: { type: 'string' }, lines: { type: 'number' }, since: { type: 'string' }, priority: { type: 'string' }, grep: { type: 'string' } })
|
|
344
|
+
},
|
|
275
345
|
{
|
|
276
346
|
name: 'firewall_cmd',
|
|
277
|
-
description: '
|
|
278
|
-
inputSchema: baseParams({
|
|
347
|
+
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.',
|
|
348
|
+
inputSchema: baseParams({
|
|
349
|
+
action: { type: 'string', enum: ['list', 'add-port', 'remove-port', 'reload'] },
|
|
350
|
+
listTarget: { type: 'string', enum: ['ports', 'services', 'all'] },
|
|
351
|
+
port: { type: 'string' },
|
|
352
|
+
zone: { type: 'string' },
|
|
353
|
+
permanent: { type: 'boolean' },
|
|
354
|
+
...confirmationParams
|
|
355
|
+
}, ['action'])
|
|
279
356
|
},
|
|
280
357
|
{
|
|
281
358
|
name: 'netstat',
|
|
282
|
-
description: 'Monitor ports/connections.',
|
|
283
|
-
inputSchema: baseParams({ args: { type: 'string' }, ...grepParam })
|
|
359
|
+
description: 'Monitor ports/connections. Use args as an array of individual option tokens, for example ["-t", "-u", "-l", "-n"].',
|
|
360
|
+
inputSchema: baseParams({ args: { type: 'array', items: { type: 'string' } }, ...grepParam })
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'ss',
|
|
364
|
+
description: 'Socket statistics. Use args as an array of individual option tokens, for example ["-t", "-u", "-l", "-n"].',
|
|
365
|
+
inputSchema: baseParams({ args: { type: 'array', items: { type: 'string' } }, ...grepParam })
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'ping_host',
|
|
369
|
+
description: 'Ping a host a fixed number of times.',
|
|
370
|
+
inputSchema: baseParams({ host: { type: 'string' }, count: { type: 'number' } }, ['host'])
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: 'traceroute',
|
|
374
|
+
description: 'Trace the network path to a host.',
|
|
375
|
+
inputSchema: baseParams({ host: { type: 'string' }, maxHops: { type: 'number' } }, ['host'])
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: 'nslookup',
|
|
379
|
+
description: 'Resolve hostnames using nslookup.',
|
|
380
|
+
inputSchema: baseParams({ host: { type: 'string' }, server: { type: 'string' } }, ['host'])
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: 'dig',
|
|
384
|
+
description: 'Resolve DNS records using dig.',
|
|
385
|
+
inputSchema: baseParams({ host: { type: 'string' }, recordType: { type: 'string' }, server: { type: 'string' } }, ['host'])
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'curl_http',
|
|
389
|
+
description: 'Perform an HTTP request with structured method, URL, headers, and optional body. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
390
|
+
inputSchema: baseParams({ method: { type: 'string' }, url: { type: 'string' }, headers: { type: 'array', items: { type: 'string' } }, body: { type: 'string' }, timeoutSeconds: { type: 'number' }, followRedirects: { type: 'boolean' }, ...confirmationParams }, ['method', 'url'])
|
|
284
391
|
},
|
|
285
392
|
// --- Stats & Process (Requirements) ---
|
|
286
393
|
{
|
|
@@ -293,6 +400,16 @@ export const toolDefinitions = [
|
|
|
293
400
|
description: 'Report a snapshot of the current processes.',
|
|
294
401
|
inputSchema: baseParams(grepParam)
|
|
295
402
|
},
|
|
403
|
+
{
|
|
404
|
+
name: 'pgrep',
|
|
405
|
+
description: 'Find process IDs by name or full command pattern.',
|
|
406
|
+
inputSchema: baseParams({ pattern: { type: 'string' }, fullCommand: { type: 'boolean' } }, ['pattern'])
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'kill_process',
|
|
410
|
+
description: 'Send a signal to a process ID. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
411
|
+
inputSchema: baseParams({ pid: { type: 'number' }, signal: { type: 'string' }, ...confirmationParams }, ['pid'])
|
|
412
|
+
},
|
|
296
413
|
{
|
|
297
414
|
name: 'df_h',
|
|
298
415
|
description: 'System disk usage.',
|
|
@@ -302,5 +419,40 @@ export const toolDefinitions = [
|
|
|
302
419
|
name: 'du_sh',
|
|
303
420
|
description: 'Directory size estimation.',
|
|
304
421
|
inputSchema: baseParams({ path: { type: 'string' }, ...grepParam }, ['path'])
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'chmod',
|
|
425
|
+
description: 'Change file mode bits. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
426
|
+
inputSchema: baseParams({ mode: { type: 'string' }, path: { type: 'string' }, recursive: { type: 'boolean' }, ...confirmationParams }, ['mode', 'path'])
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'chown',
|
|
430
|
+
description: 'Change file owner and group. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
431
|
+
inputSchema: baseParams({ owner: { type: 'string' }, path: { type: 'string' }, recursive: { type: 'boolean' }, ...confirmationParams }, ['owner', 'path'])
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: 'ln',
|
|
435
|
+
description: 'Create a link. Uses symbolic=true by default for symlinks. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
436
|
+
inputSchema: baseParams({ target: { type: 'string' }, linkPath: { type: 'string' }, symbolic: { type: 'boolean' }, force: { type: 'boolean' }, ...confirmationParams }, ['target', 'linkPath'])
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: 'tar_create',
|
|
440
|
+
description: 'Create a tar archive from one or more source paths. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
441
|
+
inputSchema: baseParams({ sourcePaths: { type: 'array', items: { type: 'string' } }, outputPath: { type: 'string' }, gzip: { type: 'boolean' }, ...confirmationParams }, ['sourcePaths', 'outputPath'])
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'tar_extract',
|
|
445
|
+
description: 'Extract a tar archive into a destination directory. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
446
|
+
inputSchema: baseParams({ archivePath: { type: 'string' }, destination: { type: 'string' }, gzip: { type: 'boolean' }, ...confirmationParams }, ['archivePath', 'destination'])
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: 'zip',
|
|
450
|
+
description: 'Create a zip archive from one or more source paths. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
451
|
+
inputSchema: baseParams({ sourcePaths: { type: 'array', items: { type: 'string' } }, outputPath: { type: 'string' }, recursive: { type: 'boolean' }, ...confirmationParams }, ['sourcePaths', 'outputPath'])
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: 'unzip',
|
|
455
|
+
description: 'Extract a zip archive into a destination directory. REQUIRES CONFIRMATION unless the final command is whitelisted.',
|
|
456
|
+
inputSchema: baseParams({ archivePath: { type: 'string' }, destination: { type: 'string' }, overwrite: { type: 'boolean' }, ...confirmationParams }, ['archivePath', 'destination'])
|
|
305
457
|
}
|
|
306
458
|
];
|
package/dist/tools/handlers.js
CHANGED
|
@@ -4,17 +4,26 @@ const WRITE_TOOLS = [
|
|
|
4
4
|
'execute_command',
|
|
5
5
|
'upload_file',
|
|
6
6
|
'edit_text_file',
|
|
7
|
+
'append_text_file',
|
|
8
|
+
'mkdir',
|
|
9
|
+
'mv',
|
|
10
|
+
'cp',
|
|
11
|
+
'replace_in_file',
|
|
7
12
|
'rm_safe',
|
|
13
|
+
'git_fetch',
|
|
8
14
|
'git_pull',
|
|
15
|
+
'git_switch',
|
|
9
16
|
'docker_compose_up',
|
|
10
17
|
'docker_compose_down',
|
|
11
18
|
'docker_compose_stop',
|
|
12
19
|
'docker_compose_restart',
|
|
20
|
+
'docker_exec',
|
|
13
21
|
'docker_pull',
|
|
14
22
|
'docker_cp',
|
|
15
23
|
'docker_stop',
|
|
16
24
|
'docker_rm',
|
|
17
25
|
'docker_start',
|
|
26
|
+
'docker_restart',
|
|
18
27
|
'docker_rmi',
|
|
19
28
|
'docker_commit',
|
|
20
29
|
'docker_load',
|
|
@@ -22,7 +31,16 @@ const WRITE_TOOLS = [
|
|
|
22
31
|
'systemctl_restart',
|
|
23
32
|
'systemctl_start',
|
|
24
33
|
'systemctl_stop',
|
|
25
|
-
'firewall_cmd'
|
|
34
|
+
'firewall_cmd',
|
|
35
|
+
'kill_process',
|
|
36
|
+
'chmod',
|
|
37
|
+
'chown',
|
|
38
|
+
'ln',
|
|
39
|
+
'tar_create',
|
|
40
|
+
'tar_extract',
|
|
41
|
+
'zip',
|
|
42
|
+
'unzip',
|
|
43
|
+
'curl_http'
|
|
26
44
|
];
|
|
27
45
|
const DEFAULT_BLACKLIST = [
|
|
28
46
|
/rm\s+-(rf|fr|r|f)\s+\//i,
|
|
@@ -38,6 +56,13 @@ export class ToolHandlers {
|
|
|
38
56
|
constructor(configManager) {
|
|
39
57
|
this.configManager = configManager;
|
|
40
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Build regex list from config patterns using case-insensitive matching to keep
|
|
61
|
+
* behavior aligned with the existing blacklist implementation.
|
|
62
|
+
*/
|
|
63
|
+
compileUserPatterns(patterns) {
|
|
64
|
+
return patterns.map((pattern) => new RegExp(pattern, 'i'));
|
|
65
|
+
}
|
|
41
66
|
getServerConfig(alias) {
|
|
42
67
|
const config = this.configManager.getServerConfig(alias);
|
|
43
68
|
if (!config) {
|
|
@@ -53,38 +78,207 @@ export class ToolHandlers {
|
|
|
53
78
|
}
|
|
54
79
|
return cwd;
|
|
55
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Escape arbitrary text as a single POSIX shell argument to avoid command
|
|
83
|
+
* injection through built-in tool parameters.
|
|
84
|
+
*/
|
|
85
|
+
shellEscape(value) {
|
|
86
|
+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* execute_command is intentionally limited to one command segment. Chaining,
|
|
90
|
+
* pipes, subshells, redirection, and multiline payloads must use safer tools
|
|
91
|
+
* or execute_batch instead.
|
|
92
|
+
*/
|
|
93
|
+
validateSingleCommand(command) {
|
|
94
|
+
this.ensureNoShellControl(command, 'execute_command only supports a single command without shell chaining, pipes, redirection, subshells, or multiline input.');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Validate free-form option fragments that intentionally allow spaces but must
|
|
98
|
+
* never introduce shell control syntax.
|
|
99
|
+
*/
|
|
100
|
+
validateShellFragment(value, fieldName) {
|
|
101
|
+
this.ensureNoShellControl(value, `${fieldName} contains forbidden shell control characters.`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate one shell token that is expected to remain a single argument.
|
|
105
|
+
*/
|
|
106
|
+
validateShellToken(value, fieldName) {
|
|
107
|
+
if (/\s/.test(value)) {
|
|
108
|
+
throw new Error(`${fieldName} must be a single token without spaces.`);
|
|
109
|
+
}
|
|
110
|
+
this.validateShellFragment(value, fieldName);
|
|
111
|
+
}
|
|
112
|
+
shellEscapeList(values) {
|
|
113
|
+
return values.map((value) => this.shellEscape(value)).join(' ');
|
|
114
|
+
}
|
|
115
|
+
validateTokenArray(values, fieldName) {
|
|
116
|
+
for (const [index, value] of (values || []).entries()) {
|
|
117
|
+
this.validateShellToken(value, `${fieldName}[${index}]`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
escapePerlEnvBase64(value) {
|
|
121
|
+
return Buffer.from(value).toString('base64');
|
|
122
|
+
}
|
|
123
|
+
ensureNoShellControl(value, errorMessage) {
|
|
124
|
+
const forbiddenOperators = [/&&/, /\|\|/, /;/, /\|/, /\$\(/, /`/, />/, /</, /\r|\n/];
|
|
125
|
+
for (const pattern of forbiddenOperators) {
|
|
126
|
+
if (pattern.test(value)) {
|
|
127
|
+
throw new Error(errorMessage);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
56
131
|
checkBlacklist(command) {
|
|
57
|
-
const userBlacklist = this.configManager.getGlobalBlacklist();
|
|
58
|
-
const
|
|
59
|
-
for (const pattern of
|
|
132
|
+
const userBlacklist = this.compileUserPatterns(this.configManager.getGlobalBlacklist());
|
|
133
|
+
const normalizedCommand = this.stripQuotedLiterals(command);
|
|
134
|
+
for (const pattern of DEFAULT_BLACKLIST) {
|
|
135
|
+
if (pattern.test(normalizedCommand)) {
|
|
136
|
+
throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const pattern of userBlacklist) {
|
|
60
140
|
if (pattern.test(command)) {
|
|
61
141
|
throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
|
|
62
142
|
}
|
|
63
143
|
}
|
|
64
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Remove quoted literal payloads before evaluating built-in default blacklist
|
|
147
|
+
* rules so escaped user arguments do not look like executable shell syntax.
|
|
148
|
+
*/
|
|
149
|
+
stripQuotedLiterals(command) {
|
|
150
|
+
return command
|
|
151
|
+
.replace(/'[^']*'/g, "''")
|
|
152
|
+
.replace(/"([^"\\]|\\.)*"/g, '""');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whitelisted execute_command payloads can bypass the confirmation flow, but
|
|
156
|
+
* they still must pass blacklist validation first.
|
|
157
|
+
*/
|
|
158
|
+
isCommandWhitelisted(command) {
|
|
159
|
+
const userWhitelist = this.compileUserPatterns(this.configManager.getGlobalWhitelist());
|
|
160
|
+
return userWhitelist.some((pattern) => pattern.test(command));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the exact shell command string that will be executed for command-based
|
|
164
|
+
* tools so that security rules operate on the same final text.
|
|
165
|
+
*/
|
|
166
|
+
getExecutableCommand(name, params) {
|
|
167
|
+
let command = this.getCommandForTool(name, params);
|
|
168
|
+
if (!command)
|
|
169
|
+
return '';
|
|
170
|
+
if (params.grep) {
|
|
171
|
+
this.validateShellFragment(params.grep, 'grep');
|
|
172
|
+
command += ` | grep -E ${this.shellEscape(params.grep)}`;
|
|
173
|
+
}
|
|
174
|
+
return command;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Determine whether the current tool invocation still needs confirmation after
|
|
178
|
+
* command whitelist rules are applied to the final executable command.
|
|
179
|
+
*/
|
|
180
|
+
requiresConfirmation(name, params) {
|
|
181
|
+
if (name !== 'execute_batch') {
|
|
182
|
+
if (!WRITE_TOOLS.includes(name))
|
|
183
|
+
return false;
|
|
184
|
+
const command = this.getExecutableCommand(name, params);
|
|
185
|
+
return command ? !this.isCommandWhitelisted(command) : true;
|
|
186
|
+
}
|
|
187
|
+
return params.commands?.some((cmd) => {
|
|
188
|
+
if (!WRITE_TOOLS.includes(cmd.name))
|
|
189
|
+
return false;
|
|
190
|
+
const command = this.getExecutableCommand(cmd.name, cmd.arguments);
|
|
191
|
+
return command ? !this.isCommandWhitelisted(command) : true;
|
|
192
|
+
}) ?? false;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Determine whether a tool invocation is fundamentally a write action,
|
|
196
|
+
* regardless of whether whitelist rules later skip manual confirmation.
|
|
197
|
+
*/
|
|
198
|
+
isWriteToolCall(name, params) {
|
|
199
|
+
if (name !== 'execute_batch') {
|
|
200
|
+
return WRITE_TOOLS.includes(name);
|
|
201
|
+
}
|
|
202
|
+
return params.commands?.some((cmd) => WRITE_TOOLS.includes(cmd.name)) ?? false;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Apply blacklist validation to every command-bearing tool invocation before
|
|
206
|
+
* confirmation and execution.
|
|
207
|
+
*/
|
|
208
|
+
validateToolCommand(name, params) {
|
|
209
|
+
if (name === 'execute_command') {
|
|
210
|
+
this.validateSingleCommand(params.command);
|
|
211
|
+
}
|
|
212
|
+
if ((name === 'netstat' || name === 'ss') && Array.isArray(params.args)) {
|
|
213
|
+
this.validateTokenArray(params.args, `${name}.args`);
|
|
214
|
+
}
|
|
215
|
+
if (name === 'docker_exec' && Array.isArray(params.args)) {
|
|
216
|
+
for (const [index, arg] of params.args.entries()) {
|
|
217
|
+
this.validateShellFragment(arg, `docker_exec.args[${index}]`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if ((name === 'tar_create' || name === 'zip') && Array.isArray(params.sourcePaths)) {
|
|
221
|
+
if (params.sourcePaths.length === 0) {
|
|
222
|
+
throw new Error(`${name}.sourcePaths must contain at least one path.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (name === 'curl_http') {
|
|
226
|
+
this.validateShellToken(String(params.method || 'GET').toUpperCase(), 'curl_http.method');
|
|
227
|
+
for (const [index, header] of (params.headers || []).entries()) {
|
|
228
|
+
this.validateShellFragment(header, `curl_http.headers[${index}]`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const command = this.getExecutableCommand(name, params);
|
|
232
|
+
if (command) {
|
|
233
|
+
this.checkBlacklist(command);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
65
236
|
getCommandForTool(name, params) {
|
|
66
237
|
switch (name) {
|
|
67
238
|
case 'get_system_info': return 'echo "USER: $(whoami)"; echo "UPTIME: $(uptime)"; echo "KERNEL: $(uname -a)"; echo "MEMORY:"; free -m';
|
|
68
|
-
case 'check_dependencies': return `for cmd in ${params.commands.join(' ')}; do which $cmd || echo "$cmd not found"; done`;
|
|
239
|
+
case 'check_dependencies': return `for cmd in ${params.commands.map((cmd) => this.shellEscape(cmd)).join(' ')}; do which "$cmd" || echo "$cmd not found"; done`;
|
|
69
240
|
case 'pwd': return 'pwd';
|
|
70
|
-
case 'cd': return `cd ${params.path}`;
|
|
241
|
+
case 'cd': return `cd ${this.shellEscape(params.path)}`;
|
|
71
242
|
case 'll': return 'ls -l';
|
|
72
|
-
case 'cat': return `cat ${params.filePath}`;
|
|
73
|
-
case 'tail': return `tail -n ${params.lines || 50} ${params.filePath}`;
|
|
74
|
-
case 'grep': return `grep ${params.ignoreCase ? '-inE' : '-nE'}
|
|
243
|
+
case 'cat': return `cat ${this.shellEscape(params.filePath)}`;
|
|
244
|
+
case 'tail': return `tail -n ${params.lines || 50} ${this.shellEscape(params.filePath)}`;
|
|
245
|
+
case 'grep': return `grep ${params.ignoreCase ? '-inE' : '-nE'} ${this.shellEscape(params.pattern)} ${this.shellEscape(params.filePath)}`;
|
|
75
246
|
case 'edit_text_file':
|
|
76
247
|
const edB64 = Buffer.from(params.content).toString('base64');
|
|
77
|
-
return `
|
|
78
|
-
case '
|
|
248
|
+
return `printf '%s' ${this.shellEscape(edB64)} | base64 -d > ${this.shellEscape(params.filePath)}`;
|
|
249
|
+
case 'append_text_file':
|
|
250
|
+
const appendB64 = Buffer.from(params.content).toString('base64');
|
|
251
|
+
return `printf '%s' ${this.shellEscape(appendB64)} | base64 -d >> ${this.shellEscape(params.filePath)}`;
|
|
252
|
+
case 'touch': return `touch ${this.shellEscape(params.filePath)}`;
|
|
253
|
+
case 'mkdir': return `mkdir ${params.parents ? '-p ' : ''}${this.shellEscape(params.path)}`;
|
|
254
|
+
case 'mv': return `mv ${params.force ? '-f ' : ''}${this.shellEscape(params.source)} ${this.shellEscape(params.destination)}`;
|
|
255
|
+
case 'cp': return `cp ${(params.recursive ? '-r ' : '') + (params.preserve ? '-p ' : '')}${this.shellEscape(params.source)} ${this.shellEscape(params.destination)}`;
|
|
256
|
+
case 'replace_in_file': {
|
|
257
|
+
const searchB64 = this.escapePerlEnvBase64(params.search);
|
|
258
|
+
const replaceB64 = this.escapePerlEnvBase64(params.replace);
|
|
259
|
+
const replaceFlag = params.replaceAll === false ? '' : 'g';
|
|
260
|
+
return `SEARCH_B64=${this.shellEscape(searchB64)} REPLACE_B64=${this.shellEscape(replaceB64)} perl -0i -M MIME::Base64 -pe ${this.shellEscape(`BEGIN { $s = decode_base64($ENV{SEARCH_B64}); $r = decode_base64($ENV{REPLACE_B64}); } s/\\Q$s\\E/$r/${replaceFlag}`)} ${this.shellEscape(params.filePath)}`;
|
|
261
|
+
}
|
|
79
262
|
case 'rm_safe':
|
|
80
263
|
const restricted = ['/', '/etc', '/usr', '/bin', '/var', '/root', '/home'];
|
|
81
264
|
if (restricted.includes(params.path.trim()))
|
|
82
265
|
throw new Error(`RM_SAFE: Denied for restricted directory.`);
|
|
83
|
-
return `rm ${params.recursive ? '-rf' : '-f'} ${params.path}`;
|
|
84
|
-
case 'echo': return `echo
|
|
85
|
-
case 'find': return `find ${params.path} -name
|
|
266
|
+
return `rm ${params.recursive ? '-rf' : '-f'} ${this.shellEscape(params.path)}`;
|
|
267
|
+
case 'echo': return `echo ${this.shellEscape(params.text)}`;
|
|
268
|
+
case 'find': return `find ${this.shellEscape(params.path)} -name ${this.shellEscape(params.name)}`;
|
|
86
269
|
case 'git_status': return 'git status';
|
|
270
|
+
case 'git_fetch': return `git fetch ${params.all ? '--all ' : ''}${params.prune ? '--prune' : ''}`.trim();
|
|
87
271
|
case 'git_pull': return 'git pull --no-edit';
|
|
272
|
+
case 'git_switch':
|
|
273
|
+
if (params.create) {
|
|
274
|
+
return `git switch -c ${this.shellEscape(params.branch)}${params.startPoint ? ` ${this.shellEscape(params.startPoint)}` : ''}`;
|
|
275
|
+
}
|
|
276
|
+
if (params.startPoint) {
|
|
277
|
+
throw new Error(`git_switch.startPoint is only valid when create=true.`);
|
|
278
|
+
}
|
|
279
|
+
return `git switch ${this.shellEscape(params.branch)}`;
|
|
280
|
+
case 'git_branch': return `git branch ${params.all ? '-a ' : ''}${params.verbose ? '-v' : ''}`.trim();
|
|
281
|
+
case 'git_log': return `git log ${params.oneline === false ? '' : '--oneline '}-n ${params.maxCount || 20}${params.path ? ` -- ${this.shellEscape(params.path)}` : ''}`.trim();
|
|
88
282
|
case 'execute_command': return params.command;
|
|
89
283
|
case 'docker_compose_up': return 'docker-compose up -d';
|
|
90
284
|
case 'docker_compose_down': return 'docker-compose down --remove-orphans';
|
|
@@ -93,30 +287,121 @@ export class ToolHandlers {
|
|
|
93
287
|
case 'docker_compose_restart': return 'docker-compose restart';
|
|
94
288
|
case 'docker_ps': return 'docker ps';
|
|
95
289
|
case 'docker_images': return 'docker images';
|
|
96
|
-
case '
|
|
97
|
-
|
|
98
|
-
case '
|
|
99
|
-
|
|
100
|
-
case '
|
|
101
|
-
|
|
102
|
-
case '
|
|
103
|
-
case '
|
|
104
|
-
case '
|
|
105
|
-
case '
|
|
106
|
-
case '
|
|
107
|
-
case '
|
|
108
|
-
case '
|
|
109
|
-
case '
|
|
290
|
+
case 'docker_exec':
|
|
291
|
+
return `docker exec${params.user ? ` --user ${this.shellEscape(params.user)}` : ''}${params.workdir ? ` --workdir ${this.shellEscape(params.workdir)}` : ''} ${this.shellEscape(params.container)} ${this.shellEscape(params.command)}${params.args?.length ? ` ${this.shellEscapeList(params.args)}` : ''}`;
|
|
292
|
+
case 'docker_inspect':
|
|
293
|
+
return `docker inspect${params.format ? ` --format ${this.shellEscape(params.format)}` : ''} ${this.shellEscape(params.target)}`;
|
|
294
|
+
case 'docker_stats':
|
|
295
|
+
return `docker stats ${params.noStream === false ? '' : '--no-stream '}${params.container ? this.shellEscape(params.container) : ''}`.trim();
|
|
296
|
+
case 'docker_pull': return `docker pull ${this.shellEscape(params.image)}`;
|
|
297
|
+
case 'docker_cp': return `docker cp ${this.shellEscape(params.source)} ${this.shellEscape(params.destination)}`;
|
|
298
|
+
case 'docker_stop': return `docker stop ${this.shellEscape(params.container)}`;
|
|
299
|
+
case 'docker_rm': return `docker rm ${this.shellEscape(params.container)}`;
|
|
300
|
+
case 'docker_start': return `docker start ${this.shellEscape(params.container)}`;
|
|
301
|
+
case 'docker_restart': return `docker restart ${this.shellEscape(params.container)}`;
|
|
302
|
+
case 'docker_rmi': return `docker rmi ${this.shellEscape(params.image)}`;
|
|
303
|
+
case 'docker_commit': return `docker commit ${this.shellEscape(params.container)} ${this.shellEscape(params.repository)}`;
|
|
304
|
+
case 'docker_logs': return `docker logs -n ${params.lines || 100} ${this.shellEscape(params.container)}`;
|
|
305
|
+
case 'docker_load': return `docker load -i ${this.shellEscape(params.path)}`;
|
|
306
|
+
case 'docker_save': return `docker save -o ${this.shellEscape(params.path)} ${this.shellEscape(params.image)}`;
|
|
307
|
+
case 'systemctl_status': return `systemctl status ${this.shellEscape(params.service)}`;
|
|
308
|
+
case 'systemctl_restart': return `systemctl restart ${this.shellEscape(params.service)}`;
|
|
309
|
+
case 'systemctl_start': return `systemctl start ${this.shellEscape(params.service)}`;
|
|
310
|
+
case 'systemctl_stop': return `systemctl stop ${this.shellEscape(params.service)}`;
|
|
110
311
|
case 'ip_addr': return 'ip addr';
|
|
111
|
-
case '
|
|
112
|
-
|
|
312
|
+
case 'journalctl':
|
|
313
|
+
return `journalctl --no-pager${params.unit ? ` -u ${this.shellEscape(params.unit)}` : ''}${params.priority ? ` -p ${this.shellEscape(params.priority)}` : ''}${params.since ? ` --since ${this.shellEscape(params.since)}` : ''} -n ${params.lines || 100}`;
|
|
314
|
+
case 'firewall_cmd':
|
|
315
|
+
return this.buildFirewallCommand(params);
|
|
316
|
+
case 'netstat':
|
|
317
|
+
return `netstat ${(params.args && params.args.length > 0) ? params.args.join(' ') : '-tuln'}`;
|
|
318
|
+
case 'ss':
|
|
319
|
+
return `ss ${(params.args && params.args.length > 0) ? params.args.join(' ') : '-tuln'}`;
|
|
320
|
+
case 'ping_host':
|
|
321
|
+
return `ping -c ${params.count || 4} ${this.shellEscape(params.host)}`;
|
|
322
|
+
case 'traceroute':
|
|
323
|
+
return `traceroute${params.maxHops ? ` -m ${params.maxHops}` : ''} ${this.shellEscape(params.host)}`;
|
|
324
|
+
case 'nslookup':
|
|
325
|
+
return `nslookup ${this.shellEscape(params.host)}${params.server ? ` ${this.shellEscape(params.server)}` : ''}`;
|
|
326
|
+
case 'dig':
|
|
327
|
+
return `dig ${this.shellEscape(params.host)}${params.recordType ? ` ${this.shellEscape(params.recordType)}` : ''}${params.server ? ` ${this.shellEscape(`@${params.server}`)}` : ''}`;
|
|
328
|
+
case 'curl_http': {
|
|
329
|
+
const method = String(params.method || 'GET').toUpperCase();
|
|
330
|
+
const headerArgs = (params.headers || []).map((header) => ` -H ${this.shellEscape(header)}`).join('');
|
|
331
|
+
const common = `curl -X ${method}${params.followRedirects ? ' -L' : ''}${params.timeoutSeconds ? ` --max-time ${params.timeoutSeconds}` : ''}${headerArgs} ${this.shellEscape(params.url)}`;
|
|
332
|
+
if (params.body !== undefined) {
|
|
333
|
+
const bodyB64 = Buffer.from(params.body).toString('base64');
|
|
334
|
+
return `printf '%s' ${this.shellEscape(bodyB64)} | base64 -d | ${common} --data-binary @-`;
|
|
335
|
+
}
|
|
336
|
+
return common;
|
|
337
|
+
}
|
|
113
338
|
case 'df_h': return 'df -h';
|
|
114
|
-
case 'du_sh': return `du -sh ${params.path}`;
|
|
339
|
+
case 'du_sh': return `du -sh ${this.shellEscape(params.path)}`;
|
|
115
340
|
case 'nvidia_smi': return 'nvidia-smi';
|
|
116
341
|
case 'ps': return 'ps aux';
|
|
342
|
+
case 'pgrep': return `pgrep ${params.fullCommand ? '-af ' : '-a '}${this.shellEscape(params.pattern)}`;
|
|
343
|
+
case 'kill_process': return `kill -s ${this.shellEscape(params.signal || 'TERM')} ${params.pid}`;
|
|
344
|
+
case 'chmod': return `chmod ${params.recursive ? '-R ' : ''}${this.shellEscape(params.mode)} ${this.shellEscape(params.path)}`;
|
|
345
|
+
case 'chown': return `chown ${params.recursive ? '-R ' : ''}${this.shellEscape(params.owner)} ${this.shellEscape(params.path)}`;
|
|
346
|
+
case 'ln': return `ln ${params.symbolic === false ? '' : '-s '}${params.force ? '-f ' : ''}${this.shellEscape(params.target)} ${this.shellEscape(params.linkPath)}`;
|
|
347
|
+
case 'tar_create':
|
|
348
|
+
return `tar ${params.gzip ? '-czf' : '-cf'} ${this.shellEscape(params.outputPath)} ${this.shellEscapeList(params.sourcePaths)}`;
|
|
349
|
+
case 'tar_extract': {
|
|
350
|
+
return `mkdir -p ${this.shellEscape(params.destination)} && tar ${params.gzip ? '-xzf' : '-xf'} ${this.shellEscape(params.archivePath)} -C ${this.shellEscape(params.destination)}`;
|
|
351
|
+
}
|
|
352
|
+
case 'zip':
|
|
353
|
+
return `zip ${params.recursive === false ? '' : '-r '}${this.shellEscape(params.outputPath)} ${this.shellEscapeList(params.sourcePaths)}`.trim();
|
|
354
|
+
case 'unzip':
|
|
355
|
+
return `mkdir -p ${this.shellEscape(params.destination)} && unzip ${params.overwrite ? '-o ' : '-n '}${this.shellEscape(params.archivePath)} -d ${this.shellEscape(params.destination)}`;
|
|
117
356
|
default: return '';
|
|
118
357
|
}
|
|
119
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Build firewall-cmd from structured inputs so the service controls the final
|
|
361
|
+
* command shape instead of accepting a free-form shell fragment.
|
|
362
|
+
*/
|
|
363
|
+
buildFirewallCommand(params) {
|
|
364
|
+
const parts = ['firewall-cmd'];
|
|
365
|
+
if (params.zone) {
|
|
366
|
+
this.validateShellToken(params.zone, 'firewall_cmd.zone');
|
|
367
|
+
parts.push(`--zone=${params.zone}`);
|
|
368
|
+
}
|
|
369
|
+
if (params.permanent) {
|
|
370
|
+
parts.push('--permanent');
|
|
371
|
+
}
|
|
372
|
+
switch (params.action) {
|
|
373
|
+
case 'reload':
|
|
374
|
+
parts.push('--reload');
|
|
375
|
+
break;
|
|
376
|
+
case 'list': {
|
|
377
|
+
const listTarget = params.listTarget || 'ports';
|
|
378
|
+
const targetMap = {
|
|
379
|
+
ports: '--list-ports',
|
|
380
|
+
services: '--list-services',
|
|
381
|
+
all: '--list-all'
|
|
382
|
+
};
|
|
383
|
+
const targetFlag = targetMap[listTarget];
|
|
384
|
+
if (!targetFlag) {
|
|
385
|
+
throw new Error(`Unsupported firewall_cmd.listTarget: ${listTarget}`);
|
|
386
|
+
}
|
|
387
|
+
parts.push(targetFlag);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case 'add-port':
|
|
391
|
+
case 'remove-port': {
|
|
392
|
+
if (!params.port) {
|
|
393
|
+
throw new Error(`firewall_cmd action '${params.action}' requires 'port'.`);
|
|
394
|
+
}
|
|
395
|
+
this.validateShellToken(params.port, 'firewall_cmd.port');
|
|
396
|
+
const flag = params.action === 'add-port' ? '--add-port' : '--remove-port';
|
|
397
|
+
parts.push(`${flag}=${params.port}`);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
default:
|
|
401
|
+
throw new Error(`Unsupported firewall_cmd action: ${params.action}`);
|
|
402
|
+
}
|
|
403
|
+
return parts.join(' ');
|
|
404
|
+
}
|
|
120
405
|
async handleTool(name, args) {
|
|
121
406
|
if (name === 'list_servers') {
|
|
122
407
|
const servers = this.configManager.getAllServers();
|
|
@@ -138,11 +423,19 @@ export class ToolHandlers {
|
|
|
138
423
|
return `Connection failed for server '${serverAlias}': ${err.message}`;
|
|
139
424
|
}
|
|
140
425
|
}
|
|
426
|
+
this.validateToolCommand(name, params);
|
|
427
|
+
if (name === 'execute_batch') {
|
|
428
|
+
for (const cmd of params.commands || []) {
|
|
429
|
+
this.validateToolCommand(cmd.name, cmd.arguments);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
141
432
|
// --- Confirmation Logic ---
|
|
142
|
-
const
|
|
433
|
+
const isWriteToolCall = this.isWriteToolCall(name, params);
|
|
434
|
+
const isWriteAction = this.requiresConfirmation(name, params);
|
|
435
|
+
if (isWriteToolCall && srv.readOnly) {
|
|
436
|
+
throw new Error(`Server '${serverAlias}' is read-only.`);
|
|
437
|
+
}
|
|
143
438
|
if (isWriteAction) {
|
|
144
|
-
if (srv.readOnly)
|
|
145
|
-
throw new Error(`Server '${serverAlias}' is read-only.`);
|
|
146
439
|
if (confirmationId && confirmExecution === true) {
|
|
147
440
|
const isValid = confirmationManager.validateAndPop(confirmationId, name, serverAlias, args);
|
|
148
441
|
if (!isValid)
|
|
@@ -163,10 +456,6 @@ export class ToolHandlers {
|
|
|
163
456
|
const commands = params.commands;
|
|
164
457
|
let results = [];
|
|
165
458
|
let currentBatchCwd = this.resolveCwd(srv, params.cwd);
|
|
166
|
-
for (const cmd of commands) {
|
|
167
|
-
if (cmd.name === 'execute_command')
|
|
168
|
-
this.checkBlacklist(cmd.arguments.command);
|
|
169
|
-
}
|
|
170
459
|
return await SSHClient.runSession(srv, async (conn) => {
|
|
171
460
|
for (const cmd of commands) {
|
|
172
461
|
if (cmd.name === 'cd') {
|
|
@@ -174,21 +463,17 @@ export class ToolHandlers {
|
|
|
174
463
|
results.push(`Directory changed to: ${currentBatchCwd}`);
|
|
175
464
|
continue;
|
|
176
465
|
}
|
|
177
|
-
let cmdStr = this.
|
|
466
|
+
let cmdStr = this.getExecutableCommand(cmd.name, cmd.arguments);
|
|
178
467
|
if (!cmdStr) {
|
|
179
468
|
results.push(`[${cmd.name}] Error: Not supported in batch.`);
|
|
180
469
|
continue;
|
|
181
470
|
}
|
|
182
|
-
if (cmd.arguments.grep)
|
|
183
|
-
cmdStr += ` | grep -E "${cmd.arguments.grep.replace(/"/g, '\\"')}"`;
|
|
184
471
|
const res = await SSHClient.executeOnConn(conn, cmdStr, currentBatchCwd, timeout);
|
|
185
472
|
results.push(`[${cmd.name}]\n${res.stdout}${res.stderr ? '\n[STDERR]\n' + res.stderr : ''}`);
|
|
186
473
|
}
|
|
187
474
|
return results.join('\n\n---\n\n');
|
|
188
475
|
});
|
|
189
476
|
}
|
|
190
|
-
if (name === 'execute_command')
|
|
191
|
-
this.checkBlacklist(params.command);
|
|
192
477
|
const cwd = this.resolveCwd(srv, params.cwd);
|
|
193
478
|
if (name === 'list_working_directories') {
|
|
194
479
|
if (!srv.workingDirectories || Object.keys(srv.workingDirectories).length === 0) {
|
|
@@ -206,10 +491,8 @@ export class ToolHandlers {
|
|
|
206
491
|
await SSHClient.downloadFile(srv, params.remotePath, params.localPath);
|
|
207
492
|
return `Successfully downloaded ${params.remotePath} to ${params.localPath}`;
|
|
208
493
|
}
|
|
209
|
-
let commandToRun = this.
|
|
494
|
+
let commandToRun = this.getExecutableCommand(name, params);
|
|
210
495
|
if (commandToRun) {
|
|
211
|
-
if (params.grep)
|
|
212
|
-
commandToRun += ` | grep -E "${params.grep.replace(/"/g, '\\"')}"`;
|
|
213
496
|
const res = await SSHClient.executeCommand(srv, commandToRun, cwd, timeout);
|
|
214
497
|
let out = res.stdout;
|
|
215
498
|
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.4.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": [
|