@jadchene/mcp-ssh-service 1.2.0 → 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 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
- * **Server-Level Read-Only**: Lock specific servers to a non-destructive mode at the configuration level.
19
- * **Restricted File Deletion**: Hardcoded prevention of accidental deletion of system-critical paths like `/etc` or `/usr`.
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
- ## ⚙️ Configuration Schema
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
- | `defaultTimeout` | number | Command timeout in milliseconds (default: 60000). |
60
- | `servers` | object | Dictionary of server configs where key is the `serverAlias`. |
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
- "servers": {
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,8 +118,62 @@ 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
 
@@ -117,7 +190,7 @@ 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
196
  ### File Management (10)
@@ -161,8 +234,8 @@ node dist/index.js --config ./config.json
161
234
  * `systemctl_start` [Auth Required]
162
235
  * `systemctl_stop` [Auth Required]
163
236
  * `ip_addr`
164
- * `firewall_cmd` [Auth Required]
165
- * `netstat`
237
+ * `firewall_cmd` [Auth Required, structured actions only]
238
+ * `netstat` [uses `args: string[]`]
166
239
 
167
240
  ### Stats & Process (4)
168
241
  * `nvidia_smi`
@@ -174,13 +247,19 @@ Total: 50 tools.
174
247
 
175
248
  ---
176
249
 
177
- ## 🔐 The Confirmation Workflow
178
-
250
+ ## 🔐 The Confirmation Workflow
251
+
179
252
  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.
253
+ 2. **Intercept**: Server returns `status: "pending"` with a `confirmationId`.
254
+ 3. **Human Input**: You review the action in your chat client and approve.
182
255
  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.
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.
184
263
 
185
264
  ---
186
265
 
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 if any sub-tool is high-risk.',
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: 'Arbitrary execution: Runs any shell command via SSH. REQUIRES CONFIRMATION.',
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,
@@ -274,13 +274,20 @@ export const toolDefinitions = [
274
274
  },
275
275
  {
276
276
  name: 'firewall_cmd',
277
- description: 'Control the runtime/permanent firewall. REQUIRES CONFIRMATION.',
278
- inputSchema: baseParams({ args: { type: 'string' }, ...confirmationParams }, ['args'])
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'])
279
286
  },
280
287
  {
281
288
  name: 'netstat',
282
- description: 'Monitor ports/connections.',
283
- 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 })
284
291
  },
285
292
  // --- Stats & Process (Requirements) ---
286
293
  {
@@ -38,6 +38,13 @@ export class ToolHandlers {
38
38
  constructor(configManager) {
39
39
  this.configManager = configManager;
40
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
+ }
41
48
  getServerConfig(alias) {
42
49
  const config = this.configManager.getServerConfig(alias);
43
50
  if (!config) {
@@ -53,36 +60,157 @@ export class ToolHandlers {
53
60
  }
54
61
  return cwd;
55
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
+ }
56
102
  checkBlacklist(command) {
57
- const userBlacklist = this.configManager.getGlobalBlacklist();
58
- const combined = [...DEFAULT_BLACKLIST, ...userBlacklist.map(p => new RegExp(p, 'i'))];
59
- for (const pattern of combined) {
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) {
60
111
  if (pattern.test(command)) {
61
112
  throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
62
113
  }
63
114
  }
64
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
+ }
65
193
  getCommandForTool(name, params) {
66
194
  switch (name) {
67
195
  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`;
196
+ case 'check_dependencies': return `for cmd in ${params.commands.map((cmd) => this.shellEscape(cmd)).join(' ')}; do which "$cmd" || echo "$cmd not found"; done`;
69
197
  case 'pwd': return 'pwd';
70
- case 'cd': return `cd ${params.path}`;
198
+ case 'cd': return `cd ${this.shellEscape(params.path)}`;
71
199
  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'} "${params.pattern.replace(/"/g, '\\"')}" ${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)}`;
75
203
  case 'edit_text_file':
76
204
  const edB64 = Buffer.from(params.content).toString('base64');
77
- return `echo "${edB64}" | base64 -d > ${params.filePath}`;
78
- case 'touch': return `touch ${params.filePath}`;
205
+ return `printf '%s' ${this.shellEscape(edB64)} | base64 -d > ${this.shellEscape(params.filePath)}`;
206
+ case 'touch': return `touch ${this.shellEscape(params.filePath)}`;
79
207
  case 'rm_safe':
80
208
  const restricted = ['/', '/etc', '/usr', '/bin', '/var', '/root', '/home'];
81
209
  if (restricted.includes(params.path.trim()))
82
210
  throw new Error(`RM_SAFE: Denied for restricted directory.`);
83
- return `rm ${params.recursive ? '-rf' : '-f'} ${params.path}`;
84
- case 'echo': return `echo "${params.text}"`;
85
- case 'find': return `find ${params.path} -name "${params.name}"`;
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)}`;
86
214
  case 'git_status': return 'git status';
87
215
  case 'git_pull': return 'git pull --no-edit';
88
216
  case 'execute_command': return params.command;
@@ -93,30 +221,81 @@ export class ToolHandlers {
93
221
  case 'docker_compose_restart': return 'docker-compose restart';
94
222
  case 'docker_ps': return 'docker ps';
95
223
  case 'docker_images': return 'docker images';
96
- case 'docker_pull': return `docker pull ${params.image}`;
97
- case 'docker_cp': return `docker cp ${params.source} ${params.destination}`;
98
- case 'docker_stop': return `docker stop ${params.container}`;
99
- case 'docker_rm': return `docker rm ${params.container}`;
100
- case 'docker_start': return `docker start ${params.container}`;
101
- case 'docker_rmi': return `docker rmi ${params.image}`;
102
- case 'docker_commit': return `docker commit ${params.container} ${params.repository}`;
103
- case 'docker_logs': return `docker logs -n ${params.lines || 100} ${params.container}`;
104
- case 'docker_load': return `docker load -i ${params.path}`;
105
- case 'docker_save': return `docker save -o ${params.path} ${params.image}`;
106
- case 'systemctl_status': return `systemctl status ${params.service}`;
107
- case 'systemctl_restart': return `systemctl restart ${params.service}`;
108
- case 'systemctl_start': return `systemctl start ${params.service}`;
109
- 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)}`;
110
238
  case 'ip_addr': return 'ip addr';
111
- case 'firewall_cmd': return `firewall-cmd ${params.args}`;
112
- case 'netstat': return `netstat ${params.args || '-tuln'}`;
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'}`;
113
246
  case 'df_h': return 'df -h';
114
- case 'du_sh': return `du -sh ${params.path}`;
247
+ case 'du_sh': return `du -sh ${this.shellEscape(params.path)}`;
115
248
  case 'nvidia_smi': return 'nvidia-smi';
116
249
  case 'ps': return 'ps aux';
117
250
  default: return '';
118
251
  }
119
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
+ }
120
299
  async handleTool(name, args) {
121
300
  if (name === 'list_servers') {
122
301
  const servers = this.configManager.getAllServers();
@@ -138,11 +317,19 @@ export class ToolHandlers {
138
317
  return `Connection failed for server '${serverAlias}': ${err.message}`;
139
318
  }
140
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
+ }
141
326
  // --- Confirmation Logic ---
142
- const isWriteAction = WRITE_TOOLS.includes(name) || (name === 'execute_batch' && params.commands?.some((c) => WRITE_TOOLS.includes(c.name)));
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
+ }
143
332
  if (isWriteAction) {
144
- if (srv.readOnly)
145
- throw new Error(`Server '${serverAlias}' is read-only.`);
146
333
  if (confirmationId && confirmExecution === true) {
147
334
  const isValid = confirmationManager.validateAndPop(confirmationId, name, serverAlias, args);
148
335
  if (!isValid)
@@ -163,10 +350,6 @@ export class ToolHandlers {
163
350
  const commands = params.commands;
164
351
  let results = [];
165
352
  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
353
  return await SSHClient.runSession(srv, async (conn) => {
171
354
  for (const cmd of commands) {
172
355
  if (cmd.name === 'cd') {
@@ -174,21 +357,17 @@ export class ToolHandlers {
174
357
  results.push(`Directory changed to: ${currentBatchCwd}`);
175
358
  continue;
176
359
  }
177
- let cmdStr = this.getCommandForTool(cmd.name, cmd.arguments);
360
+ let cmdStr = this.getExecutableCommand(cmd.name, cmd.arguments);
178
361
  if (!cmdStr) {
179
362
  results.push(`[${cmd.name}] Error: Not supported in batch.`);
180
363
  continue;
181
364
  }
182
- if (cmd.arguments.grep)
183
- cmdStr += ` | grep -E "${cmd.arguments.grep.replace(/"/g, '\\"')}"`;
184
365
  const res = await SSHClient.executeOnConn(conn, cmdStr, currentBatchCwd, timeout);
185
366
  results.push(`[${cmd.name}]\n${res.stdout}${res.stderr ? '\n[STDERR]\n' + res.stderr : ''}`);
186
367
  }
187
368
  return results.join('\n\n---\n\n');
188
369
  });
189
370
  }
190
- if (name === 'execute_command')
191
- this.checkBlacklist(params.command);
192
371
  const cwd = this.resolveCwd(srv, params.cwd);
193
372
  if (name === 'list_working_directories') {
194
373
  if (!srv.workingDirectories || Object.keys(srv.workingDirectories).length === 0) {
@@ -206,10 +385,8 @@ export class ToolHandlers {
206
385
  await SSHClient.downloadFile(srv, params.remotePath, params.localPath);
207
386
  return `Successfully downloaded ${params.remotePath} to ${params.localPath}`;
208
387
  }
209
- let commandToRun = this.getCommandForTool(name, params);
388
+ let commandToRun = this.getExecutableCommand(name, params);
210
389
  if (commandToRun) {
211
- if (params.grep)
212
- commandToRun += ` | grep -E "${params.grep.replace(/"/g, '\\"')}"`;
213
390
  const res = await SSHClient.executeCommand(srv, commandToRun, cwd, timeout);
214
391
  let out = res.stdout;
215
392
  if (res.stderr)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jadchene/mcp-ssh-service",
3
- "version": "1.2.0",
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
- "prepare": "npm run build",
16
- "watch": "tsc -w",
17
- "prepublishOnly": "npm run build",
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": [