cli-mcp-mapper 1.0.2 → 1.0.4

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.
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main", "develop" ]
6
+ pull_request:
7
+ branches: [ "main", "develop" ]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build-and-test:
14
+ runs-on: ubuntu-latest
15
+
16
+ strategy:
17
+ matrix:
18
+ node-version: [18.x, 20.x, 22.x]
19
+
20
+ steps:
21
+ - name: Checkout code
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Setup Node.js ${{ matrix.node-version }}
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: ${{ matrix.node-version }}
28
+ cache: 'npm'
29
+
30
+ - name: Install dependencies
31
+ run: npm ci
32
+
33
+ - name: Run tests
34
+ run: npm test
35
+
36
+ - name: Build (if build script exists)
37
+ run: npm run build --if-present
@@ -41,6 +41,9 @@ jobs:
41
41
  echo "✅ Tag v$VERSION does not exist, proceeding..."
42
42
  fi
43
43
 
44
+ - run: npm ci
45
+ - run: npm test
46
+
44
47
  - name: Create and push tag
45
48
  run: |
46
49
  VERSION=${{ steps.package-version.outputs.version }}
@@ -50,6 +53,5 @@ jobs:
50
53
  git push origin "v$VERSION"
51
54
  echo "✅ Created and pushed tag v$VERSION"
52
55
 
53
- - run: npm ci
54
56
  - run: npm run build --if-present
55
57
  - run: npm publish
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://raw.githubusercontent.com/SteffenBlake/cli-mcp-mapper/refs/heads/main/LICENSE)
4
4
  [![npm version](https://img.shields.io/npm/v/cli-mcp-mapper.svg)](https://www.npmjs.com/package/cli-mcp-mapper)
5
+ [![CI](https://github.com/SteffenBlake/cli-mcp-mapper/actions/workflows/ci.yml/badge.svg)](https://github.com/SteffenBlake/cli-mcp-mapper/actions/workflows/ci.yml)
5
6
 
6
7
  **Transform any CLI command into a Model Context Protocol (MCP) tool with simple JSON configuration.**
7
8
 
@@ -622,26 +623,34 @@ jq -s '{"commands": (map(.commands) | add)}' \
622
623
 
623
624
  ⚠️ **Important Security Notes:**
624
625
 
625
- 1. **Command Injection Risk**: CLI MCP Mapper executes real system commands. Only expose commands you trust.
626
+ 1. **Command Injection Protection**: CLI MCP Mapper uses Node.js `spawn` without shell interpretation to prevent command injection attacks. While we have comprehensive tests to validate this protection, **you should always run agents with shell access in containerized or sandboxed environments** (e.g., Docker, VM, or other isolation mechanisms) to minimize potential security risks. This provides an additional layer of defense in case of unforeseen vulnerabilities.
626
627
 
627
- 2. **Access Control**: MCP clients that connect to CLI MCP Mapper will have the same permissions as the user running it.
628
+ 2. **Containerization Best Practice**: When deploying CLI MCP Mapper in production or exposing it to AI agents, strongly consider running it within:
629
+ - Docker containers with limited privileges
630
+ - Virtual machines with restricted network access
631
+ - Sandboxed environments with filesystem restrictions
632
+ - Isolated user accounts with minimal permissions
628
633
 
629
- 3. **Validate Parameters**: Use `enum` to restrict parameter values when possible.
634
+ 3. **Access Control**: MCP clients that connect to CLI MCP Mapper will have the same permissions as the user running it. Run the server with the least privileged user account necessary.
630
635
 
631
- 4. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
636
+ 4. **Validate Parameters**: Use `enum` to restrict parameter values when possible to prevent unexpected inputs.
637
+
638
+ 5. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
632
639
  - `rm -rf` without proper constraints
633
640
  - `chmod` with unrestricted modes
634
641
  - Commands that can execute arbitrary code
635
642
  - Network commands that could expose sensitive data
643
+ - System administration commands
636
644
 
637
- 5. **Read-Only Operations**: Consider starting with read-only commands (ls, cat, git status) before exposing write operations.
645
+ 6. **Read-Only Operations**: Consider starting with read-only commands (ls, cat, git status) before exposing write operations.
638
646
 
639
- 6. **Configuration Security**:
647
+ 7. **Configuration Security**:
640
648
  - Don't commit sensitive configurations to version control
641
649
  - Use environment-specific configuration files
642
650
  - Review configurations before deploying
651
+ - Regularly audit which commands are exposed
643
652
 
644
- 7. **Least Privilege**: Only grant the minimum necessary commands for your use case.
653
+ 8. **Least Privilege**: Only grant the minimum necessary commands for your use case.
645
654
 
646
655
  **Best Practices:**
647
656
 
package/index.js CHANGED
@@ -3,10 +3,10 @@
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
- import { spawn } from "child_process";
7
6
  import { readFile } from "fs/promises";
8
7
  import { homedir } from "os";
9
8
  import { join } from "path";
9
+ import { buildInputSchema, buildCommand, executeCommand } from "./lib.js";
10
10
 
11
11
  // Load config from env variable or default path
12
12
  const defaultConfig = join(homedir(), ".config", "cli-mcp-mapper", "commands.json");
@@ -61,82 +61,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
61
61
  };
62
62
  });
63
63
 
64
- function buildInputSchema(parameters) {
65
- const properties = {};
66
- const required = [];
67
-
68
- for (const [name, param] of Object.entries(parameters || {})) {
69
- properties[name] = {
70
- type: param.type,
71
- description: param.description,
72
- };
73
-
74
- if (param.enum) properties[name].enum = param.enum;
75
- if (param.default) properties[name].default = param.default;
76
- if (param.required) required.push(name);
77
- }
78
-
79
- return {
80
- type: "object",
81
- properties,
82
- required,
83
- };
84
- }
85
-
86
- function buildCommand(commandConfig, args) {
87
- const cmd = [commandConfig.command, ...(commandConfig.baseArgs || [])];
88
-
89
- // Add positional args first
90
- const positional = Object.entries(commandConfig.parameters || {})
91
- .filter(([_, param]) => param.position !== undefined)
92
- .sort(([_, a], [__, b]) => a.position - b.position);
93
-
94
- for (const [name, param] of positional) {
95
- if (args[name] !== undefined) {
96
- cmd.push(String(args[name]));
97
- }
98
- }
99
-
100
- // Add named args
101
- for (const [name, param] of Object.entries(commandConfig.parameters || {})) {
102
- if (param.position !== undefined) continue; // Skip positional
103
- if (args[name] === undefined) continue; // Skip if not provided
104
-
105
- if (param.type === "boolean") {
106
- if (args[name] === true) {
107
- cmd.push(param.argName);
108
- if (param.argValue) cmd.push(param.argValue);
109
- }
110
- } else {
111
- cmd.push(param.argName);
112
- cmd.push(String(args[name]));
113
- }
114
- }
115
-
116
- return cmd;
117
- }
118
-
119
- function executeCommand(cmdArray) {
120
- return new Promise((resolve, reject) => {
121
- const [command, ...args] = cmdArray;
122
- const proc = spawn(command, args, { shell: true });
123
-
124
- let stdout = "";
125
- let stderr = "";
126
-
127
- proc.stdout.on("data", (data) => (stdout += data));
128
- proc.stderr.on("data", (data) => (stderr += data));
129
-
130
- proc.on("close", (code) => {
131
- if (code !== 0) {
132
- reject(new Error(`Command failed with code ${code}\n${stderr}`));
133
- } else {
134
- resolve(stdout || stderr);
135
- }
136
- });
137
- });
138
- }
139
-
140
64
  // Start server
141
65
  const transport = new StdioServerTransport();
142
66
  await server.connect(transport);
package/lib.js ADDED
@@ -0,0 +1,102 @@
1
+ import { spawn } from "child_process";
2
+
3
+ /**
4
+ * Build JSON schema for MCP tool input parameters
5
+ * @param {Object} parameters - Parameter definitions from config
6
+ * @returns {Object} JSON schema object
7
+ */
8
+ export function buildInputSchema(parameters) {
9
+ const properties = {};
10
+ const required = [];
11
+
12
+ for (const [name, param] of Object.entries(parameters || {})) {
13
+ properties[name] = {
14
+ type: param.type,
15
+ description: param.description,
16
+ };
17
+
18
+ if (param.enum) properties[name].enum = param.enum;
19
+ if (param.default) properties[name].default = param.default;
20
+ if (param.required) required.push(name);
21
+ }
22
+
23
+ return {
24
+ type: "object",
25
+ properties,
26
+ required,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Build command array from config and arguments
32
+ * @param {Object} commandConfig - Command configuration
33
+ * @param {Object} args - Arguments passed to the tool
34
+ * @returns {Array<string>} Command array [command, arg1, arg2, ...]
35
+ */
36
+ export function buildCommand(commandConfig, args) {
37
+ const cmd = [commandConfig.command, ...(commandConfig.baseArgs || [])];
38
+
39
+ // Add positional args first
40
+ const positional = Object.entries(commandConfig.parameters || {})
41
+ .filter(([_, param]) => param.position !== undefined)
42
+ .sort(([_, a], [__, b]) => a.position - b.position);
43
+
44
+ for (const [name, param] of positional) {
45
+ if (args[name] !== undefined) {
46
+ cmd.push(String(args[name]));
47
+ }
48
+ }
49
+
50
+ // Add named args
51
+ for (const [name, param] of Object.entries(commandConfig.parameters || {})) {
52
+ if (param.position !== undefined) continue; // Skip positional
53
+ if (args[name] === undefined) continue; // Skip if not provided
54
+
55
+ if (param.type === "boolean") {
56
+ if (args[name] === true) {
57
+ cmd.push(param.argName);
58
+ if (param.argValue) cmd.push(param.argValue);
59
+ }
60
+ } else {
61
+ cmd.push(param.argName);
62
+ cmd.push(String(args[name]));
63
+ }
64
+ }
65
+
66
+ return cmd;
67
+ }
68
+
69
+ /**
70
+ * Execute command safely without shell interpretation
71
+ * @param {Array<string>} cmdArray - Command array [command, arg1, arg2, ...]
72
+ * @returns {Promise<string>} Command output
73
+ */
74
+ export function executeCommand(cmdArray) {
75
+ return new Promise((resolve, reject) => {
76
+ const [command, ...args] = cmdArray;
77
+ // SECURITY FIX: Remove shell: true to prevent command injection
78
+ // spawn will now execute the command directly without shell interpretation
79
+ const proc = spawn(command, args);
80
+
81
+ let stdout = "";
82
+ let stderr = "";
83
+
84
+ proc.stdout.on("data", (data) => (stdout += data));
85
+ proc.stderr.on("data", (data) => (stderr += data));
86
+
87
+ proc.on("close", (code) => {
88
+ if (code !== 0) {
89
+ // Resolve with full output instead of rejecting
90
+ // This allows MCP SDK clients to see the actual error details
91
+ const output = `Command exited with code ${code}\n${stdout}${stderr}`;
92
+ resolve(output);
93
+ } else {
94
+ resolve(stdout || stderr);
95
+ }
96
+ });
97
+
98
+ proc.on("error", (err) => {
99
+ reject(new Error(`Failed to execute command: ${err.message}`));
100
+ });
101
+ });
102
+ }
package/lib.test.js ADDED
@@ -0,0 +1,630 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { buildInputSchema, buildCommand, executeCommand } from './lib.js';
3
+
4
+ describe('buildInputSchema', () => {
5
+ it('should build schema with string parameter', () => {
6
+ const params = {
7
+ name: {
8
+ type: 'string',
9
+ description: 'User name',
10
+ required: true
11
+ }
12
+ };
13
+
14
+ const schema = buildInputSchema(params);
15
+
16
+ expect(schema).toEqual({
17
+ type: 'object',
18
+ properties: {
19
+ name: {
20
+ type: 'string',
21
+ description: 'User name'
22
+ }
23
+ },
24
+ required: ['name']
25
+ });
26
+ });
27
+
28
+ it('should build schema with boolean parameter', () => {
29
+ const params = {
30
+ verbose: {
31
+ type: 'boolean',
32
+ description: 'Enable verbose output'
33
+ }
34
+ };
35
+
36
+ const schema = buildInputSchema(params);
37
+
38
+ expect(schema).toEqual({
39
+ type: 'object',
40
+ properties: {
41
+ verbose: {
42
+ type: 'boolean',
43
+ description: 'Enable verbose output'
44
+ }
45
+ },
46
+ required: []
47
+ });
48
+ });
49
+
50
+ it('should build schema with number parameter', () => {
51
+ const params = {
52
+ count: {
53
+ type: 'number',
54
+ description: 'Number of items',
55
+ default: 10
56
+ }
57
+ };
58
+
59
+ const schema = buildInputSchema(params);
60
+
61
+ expect(schema).toEqual({
62
+ type: 'object',
63
+ properties: {
64
+ count: {
65
+ type: 'number',
66
+ description: 'Number of items',
67
+ default: 10
68
+ }
69
+ },
70
+ required: []
71
+ });
72
+ });
73
+
74
+ it('should build schema with enum parameter', () => {
75
+ const params = {
76
+ level: {
77
+ type: 'string',
78
+ description: 'Log level',
79
+ enum: ['debug', 'info', 'warn', 'error']
80
+ }
81
+ };
82
+
83
+ const schema = buildInputSchema(params);
84
+
85
+ expect(schema).toEqual({
86
+ type: 'object',
87
+ properties: {
88
+ level: {
89
+ type: 'string',
90
+ description: 'Log level',
91
+ enum: ['debug', 'info', 'warn', 'error']
92
+ }
93
+ },
94
+ required: []
95
+ });
96
+ });
97
+
98
+ it('should handle empty parameters', () => {
99
+ const schema = buildInputSchema({});
100
+
101
+ expect(schema).toEqual({
102
+ type: 'object',
103
+ properties: {},
104
+ required: []
105
+ });
106
+ });
107
+
108
+ it('should handle undefined parameters', () => {
109
+ const schema = buildInputSchema(undefined);
110
+
111
+ expect(schema).toEqual({
112
+ type: 'object',
113
+ properties: {},
114
+ required: []
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('buildCommand', () => {
120
+ it('should build basic command with no args', () => {
121
+ const config = {
122
+ command: 'echo',
123
+ baseArgs: ['hello']
124
+ };
125
+
126
+ const cmd = buildCommand(config, {});
127
+
128
+ expect(cmd).toEqual(['echo', 'hello']);
129
+ });
130
+
131
+ it('should build command with positional arguments', () => {
132
+ const config = {
133
+ command: 'echo',
134
+ baseArgs: [],
135
+ parameters: {
136
+ message: {
137
+ type: 'string',
138
+ description: 'Message to print',
139
+ position: 0
140
+ }
141
+ }
142
+ };
143
+
144
+ const cmd = buildCommand(config, { message: 'test message' });
145
+
146
+ expect(cmd).toEqual(['echo', 'test message']);
147
+ });
148
+
149
+ it('should build command with multiple positional arguments in order', () => {
150
+ const config = {
151
+ command: 'cp',
152
+ baseArgs: [],
153
+ parameters: {
154
+ source: {
155
+ type: 'string',
156
+ description: 'Source file',
157
+ position: 0
158
+ },
159
+ dest: {
160
+ type: 'string',
161
+ description: 'Destination file',
162
+ position: 1
163
+ }
164
+ }
165
+ };
166
+
167
+ const cmd = buildCommand(config, { source: 'file1.txt', dest: 'file2.txt' });
168
+
169
+ expect(cmd).toEqual(['cp', 'file1.txt', 'file2.txt']);
170
+ });
171
+
172
+ it('should build command with named string argument', () => {
173
+ const config = {
174
+ command: 'git',
175
+ baseArgs: ['commit'],
176
+ parameters: {
177
+ message: {
178
+ type: 'string',
179
+ description: 'Commit message',
180
+ argName: '-m'
181
+ }
182
+ }
183
+ };
184
+
185
+ const cmd = buildCommand(config, { message: 'Initial commit' });
186
+
187
+ expect(cmd).toEqual(['git', 'commit', '-m', 'Initial commit']);
188
+ });
189
+
190
+ it('should build command with boolean flag when true', () => {
191
+ const config = {
192
+ command: 'ls',
193
+ baseArgs: [],
194
+ parameters: {
195
+ all: {
196
+ type: 'boolean',
197
+ description: 'Show all files',
198
+ argName: '-a'
199
+ }
200
+ }
201
+ };
202
+
203
+ const cmd = buildCommand(config, { all: true });
204
+
205
+ expect(cmd).toEqual(['ls', '-a']);
206
+ });
207
+
208
+ it('should omit boolean flag when false', () => {
209
+ const config = {
210
+ command: 'ls',
211
+ baseArgs: [],
212
+ parameters: {
213
+ all: {
214
+ type: 'boolean',
215
+ description: 'Show all files',
216
+ argName: '-a'
217
+ }
218
+ }
219
+ };
220
+
221
+ const cmd = buildCommand(config, { all: false });
222
+
223
+ expect(cmd).toEqual(['ls']);
224
+ });
225
+
226
+ it('should build command with mixed positional and named args', () => {
227
+ const config = {
228
+ command: 'find',
229
+ baseArgs: [],
230
+ parameters: {
231
+ path: {
232
+ type: 'string',
233
+ description: 'Search path',
234
+ position: 0
235
+ },
236
+ name: {
237
+ type: 'string',
238
+ description: 'File name pattern',
239
+ argName: '-name'
240
+ },
241
+ type: {
242
+ type: 'string',
243
+ description: 'File type',
244
+ argName: '-type'
245
+ }
246
+ }
247
+ };
248
+
249
+ const cmd = buildCommand(config, {
250
+ path: '/tmp',
251
+ name: '*.txt',
252
+ type: 'f'
253
+ });
254
+
255
+ expect(cmd).toEqual(['find', '/tmp', '-name', '*.txt', '-type', 'f']);
256
+ });
257
+
258
+ it('should convert number arguments to strings', () => {
259
+ const config = {
260
+ command: 'head',
261
+ baseArgs: [],
262
+ parameters: {
263
+ lines: {
264
+ type: 'number',
265
+ description: 'Number of lines',
266
+ argName: '-n'
267
+ }
268
+ }
269
+ };
270
+
271
+ const cmd = buildCommand(config, { lines: 10 });
272
+
273
+ expect(cmd).toEqual(['head', '-n', '10']);
274
+ });
275
+
276
+ it('should handle boolean with argValue', () => {
277
+ const config = {
278
+ command: 'test-cmd',
279
+ baseArgs: [],
280
+ parameters: {
281
+ flag: {
282
+ type: 'boolean',
283
+ description: 'Test flag',
284
+ argName: '--flag',
285
+ argValue: 'value'
286
+ }
287
+ }
288
+ };
289
+
290
+ const cmd = buildCommand(config, { flag: true });
291
+
292
+ expect(cmd).toEqual(['test-cmd', '--flag', 'value']);
293
+ });
294
+ });
295
+
296
+ describe('Command Injection Security Tests', () => {
297
+ describe('String parameter injection attempts', () => {
298
+ it('should safely handle command substitution attempt with $(...)', async () => {
299
+ const config = {
300
+ command: 'echo',
301
+ baseArgs: [],
302
+ parameters: {
303
+ message: {
304
+ type: 'string',
305
+ description: 'Message',
306
+ position: 0
307
+ }
308
+ }
309
+ };
310
+
311
+ const cmd = buildCommand(config, { message: '$(whoami)' });
312
+
313
+ // Command should be built as array, not interpreted by shell
314
+ expect(cmd).toEqual(['echo', '$(whoami)']);
315
+
316
+ // Execute and verify the literal string is echoed, not evaluated
317
+ const result = await executeCommand(cmd);
318
+ expect(result.trim()).toBe('$(whoami)');
319
+ });
320
+
321
+ it('should safely handle command substitution with backticks', async () => {
322
+ const config = {
323
+ command: 'echo',
324
+ baseArgs: [],
325
+ parameters: {
326
+ message: {
327
+ type: 'string',
328
+ description: 'Message',
329
+ position: 0
330
+ }
331
+ }
332
+ };
333
+
334
+ const cmd = buildCommand(config, { message: '`whoami`' });
335
+
336
+ expect(cmd).toEqual(['echo', '`whoami`']);
337
+
338
+ const result = await executeCommand(cmd);
339
+ expect(result.trim()).toBe('`whoami`');
340
+ });
341
+
342
+ it('should safely handle pipe attempts', async () => {
343
+ const config = {
344
+ command: 'echo',
345
+ baseArgs: [],
346
+ parameters: {
347
+ message: {
348
+ type: 'string',
349
+ description: 'Message',
350
+ position: 0
351
+ }
352
+ }
353
+ };
354
+
355
+ const cmd = buildCommand(config, { message: 'test | cat /etc/passwd' });
356
+
357
+ expect(cmd).toEqual(['echo', 'test | cat /etc/passwd']);
358
+
359
+ const result = await executeCommand(cmd);
360
+ expect(result.trim()).toBe('test | cat /etc/passwd');
361
+ });
362
+
363
+ it('should safely handle semicolon command chaining', async () => {
364
+ const config = {
365
+ command: 'echo',
366
+ baseArgs: [],
367
+ parameters: {
368
+ message: {
369
+ type: 'string',
370
+ description: 'Message',
371
+ position: 0
372
+ }
373
+ }
374
+ };
375
+
376
+ const cmd = buildCommand(config, { message: 'hello; rm -rf /' });
377
+
378
+ expect(cmd).toEqual(['echo', 'hello; rm -rf /']);
379
+
380
+ const result = await executeCommand(cmd);
381
+ expect(result.trim()).toBe('hello; rm -rf /');
382
+ });
383
+
384
+ it('should safely handle && command chaining', async () => {
385
+ const config = {
386
+ command: 'echo',
387
+ baseArgs: [],
388
+ parameters: {
389
+ message: {
390
+ type: 'string',
391
+ description: 'Message',
392
+ position: 0
393
+ }
394
+ }
395
+ };
396
+
397
+ const cmd = buildCommand(config, { message: 'test && cat /etc/passwd' });
398
+
399
+ expect(cmd).toEqual(['echo', 'test && cat /etc/passwd']);
400
+
401
+ const result = await executeCommand(cmd);
402
+ expect(result.trim()).toBe('test && cat /etc/passwd');
403
+ });
404
+
405
+ it('should safely handle || command chaining', async () => {
406
+ const config = {
407
+ command: 'echo',
408
+ baseArgs: [],
409
+ parameters: {
410
+ message: {
411
+ type: 'string',
412
+ description: 'Message',
413
+ position: 0
414
+ }
415
+ }
416
+ };
417
+
418
+ const cmd = buildCommand(config, { message: 'test || whoami' });
419
+
420
+ expect(cmd).toEqual(['echo', 'test || whoami']);
421
+
422
+ const result = await executeCommand(cmd);
423
+ expect(result.trim()).toBe('test || whoami');
424
+ });
425
+
426
+ it('should safely handle redirection attempts', async () => {
427
+ const config = {
428
+ command: 'echo',
429
+ baseArgs: [],
430
+ parameters: {
431
+ message: {
432
+ type: 'string',
433
+ description: 'Message',
434
+ position: 0
435
+ }
436
+ }
437
+ };
438
+
439
+ const cmd = buildCommand(config, { message: 'test > /tmp/hacked' });
440
+
441
+ expect(cmd).toEqual(['echo', 'test > /tmp/hacked']);
442
+
443
+ const result = await executeCommand(cmd);
444
+ expect(result.trim()).toBe('test > /tmp/hacked');
445
+ });
446
+
447
+ it('should safely handle newline injection', async () => {
448
+ const config = {
449
+ command: 'echo',
450
+ baseArgs: [],
451
+ parameters: {
452
+ message: {
453
+ type: 'string',
454
+ description: 'Message',
455
+ position: 0
456
+ }
457
+ }
458
+ };
459
+
460
+ const cmd = buildCommand(config, { message: 'test\nwhoami' });
461
+
462
+ expect(cmd).toEqual(['echo', 'test\nwhoami']);
463
+
464
+ const result = await executeCommand(cmd);
465
+ expect(result).toContain('test\nwhoami');
466
+ });
467
+ });
468
+
469
+ describe('Named argument injection attempts', () => {
470
+ it('should safely handle injection in named string arguments', async () => {
471
+ const config = {
472
+ command: 'git',
473
+ baseArgs: ['commit'],
474
+ parameters: {
475
+ message: {
476
+ type: 'string',
477
+ description: 'Commit message',
478
+ argName: '-m'
479
+ }
480
+ }
481
+ };
482
+
483
+ const cmd = buildCommand(config, { message: '$(whoami) hack' });
484
+
485
+ // Should be separate array elements, not shell-interpreted
486
+ expect(cmd).toEqual(['git', 'commit', '-m', '$(whoami) hack']);
487
+ });
488
+
489
+ it('should safely handle injection attempts in number arguments', async () => {
490
+ const config = {
491
+ command: 'head',
492
+ baseArgs: [],
493
+ parameters: {
494
+ lines: {
495
+ type: 'number',
496
+ description: 'Number of lines',
497
+ argName: '-n'
498
+ }
499
+ }
500
+ };
501
+
502
+ // Try to inject through number parameter
503
+ const cmd = buildCommand(config, { lines: '10; whoami' });
504
+
505
+ expect(cmd).toEqual(['head', '-n', '10; whoami']);
506
+ });
507
+ });
508
+
509
+ describe('Boolean parameter injection attempts', () => {
510
+ it('should not allow injection through boolean parameters', () => {
511
+ const config = {
512
+ command: 'ls',
513
+ baseArgs: [],
514
+ parameters: {
515
+ all: {
516
+ type: 'boolean',
517
+ description: 'Show all',
518
+ argName: '-a'
519
+ }
520
+ }
521
+ };
522
+
523
+ // Boolean parameters only add the flag when value is exactly true
524
+ // String values won't trigger the flag even if truthy in JavaScript
525
+ const cmd = buildCommand(config, { all: '$(whoami)' });
526
+
527
+ // The string '$(whoami)' is truthy but not === true, so flag should be omitted
528
+ expect(cmd).toEqual(['ls']);
529
+ });
530
+ });
531
+
532
+ describe('Multiple parameter injection attempts', () => {
533
+ it('should safely handle injection across multiple parameters', async () => {
534
+ const config = {
535
+ command: 'find',
536
+ baseArgs: [],
537
+ parameters: {
538
+ path: {
539
+ type: 'string',
540
+ description: 'Search path',
541
+ position: 0
542
+ },
543
+ name: {
544
+ type: 'string',
545
+ description: 'File name',
546
+ argName: '-name'
547
+ }
548
+ }
549
+ };
550
+
551
+ const cmd = buildCommand(config, {
552
+ path: '.; whoami; echo',
553
+ name: '*.txt && cat /etc/passwd'
554
+ });
555
+
556
+ // Both should remain as literal strings
557
+ expect(cmd).toEqual(['find', '.; whoami; echo', '-name', '*.txt && cat /etc/passwd']);
558
+ });
559
+ });
560
+ });
561
+
562
+ describe('Error handling', () => {
563
+ it('should resolve with exit code info when command fails', async () => {
564
+ const cmd = ['false']; // 'false' command always returns exit code 1
565
+
566
+ const result = await executeCommand(cmd);
567
+ expect(result).toContain('Command exited with code');
568
+ expect(result).toContain('1');
569
+ });
570
+
571
+ it('should reject when command does not exist', async () => {
572
+ const cmd = ['this-command-definitely-does-not-exist-12345'];
573
+
574
+ await expect(executeCommand(cmd)).rejects.toThrow('Failed to execute command');
575
+ });
576
+
577
+ it('should resolve with stderr content on failure', async () => {
578
+ // Use a command that writes to stderr
579
+ const cmd = ['ls', '/this/path/does/not/exist/12345'];
580
+
581
+ const result = await executeCommand(cmd);
582
+ expect(result).toContain('Command exited with code');
583
+ expect(result.toLowerCase()).toMatch(/no such file or directory|cannot access/);
584
+ });
585
+
586
+ it('should resolve with full output including stderr when command fails with non-zero exit code', async () => {
587
+ // Use a command that will fail and output to stderr
588
+ // This command tries to list a non-existent directory
589
+ const cmd = ['ls', '/this/path/does/not/exist/for/testing/12345'];
590
+
591
+ // The promise should resolve (not reject) with the full output
592
+ const result = await executeCommand(cmd);
593
+
594
+ // Result should contain exit code information
595
+ expect(result).toContain('Command exited with code');
596
+
597
+ // Result should contain stderr output with error message
598
+ expect(result.toLowerCase()).toMatch(/no such file or directory|cannot access/);
599
+ });
600
+ });
601
+
602
+ describe('Normal operation tests', () => {
603
+ it('should execute simple echo command', async () => {
604
+ const cmd = ['echo', 'hello world'];
605
+ const result = await executeCommand(cmd);
606
+
607
+ expect(result.trim()).toBe('hello world');
608
+ });
609
+
610
+ it('should execute command with multiple arguments', async () => {
611
+ const cmd = ['echo', 'arg1', 'arg2', 'arg3'];
612
+ const result = await executeCommand(cmd);
613
+
614
+ expect(result.trim()).toBe('arg1 arg2 arg3');
615
+ });
616
+
617
+ it('should handle arguments with spaces', async () => {
618
+ const cmd = ['echo', 'hello world', 'foo bar'];
619
+ const result = await executeCommand(cmd);
620
+
621
+ expect(result.trim()).toBe('hello world foo bar');
622
+ });
623
+
624
+ it('should handle arguments with special characters (non-shell)', async () => {
625
+ const cmd = ['echo', '@#$%^&*()'];
626
+ const result = await executeCommand(cmd);
627
+
628
+ expect(result.trim()).toBe('@#$%^&*()');
629
+ });
630
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-mcp-mapper",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "mcp server for mapping cli commands to mcp tools",
5
5
  "homepage": "https://github.com/SteffenBlake/cli-mcp-mapper#readme",
6
6
  "bugs": {
@@ -16,7 +16,13 @@
16
16
  "cli-mcp-mapper": "index.js"
17
17
  },
18
18
  "type": "module",
19
+ "scripts": {
20
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
21
+ },
19
22
  "dependencies": {
20
23
  "@modelcontextprotocol/sdk": "^1.25.3"
24
+ },
25
+ "devDependencies": {
26
+ "jest": "^30.2.0"
21
27
  }
22
28
  }