cli-mcp-mapper 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,7 +1,8 @@
1
1
  # CLI MCP Mapper
2
2
 
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
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
 
@@ -66,22 +67,16 @@ The Model Context Protocol (MCP) enables AI assistants to interact with external
66
67
 
67
68
  ## Installation
68
69
 
69
- Install CLI MCP Mapper globally from GitHub:
70
+ Install CLI MCP Mapper globally from NPM:
70
71
 
71
72
  ```bash
72
- npm install -g git+https://github.com/SteffenBlake/cli-mcp-mapper.git
73
- ```
74
-
75
- Or install from npm (once published):
76
-
77
- ```bash
78
- npm install -g cli-mcp-mapper
73
+ npm i -g cli-mcp-mapper
79
74
  ```
80
75
 
81
76
  Verify the installation:
82
77
 
83
78
  ```bash
84
- cli-mcp-mapper --version
79
+ which cli-mcp-mapper
85
80
  ```
86
81
 
87
82
  ## Configuration
@@ -628,26 +623,34 @@ jq -s '{"commands": (map(.commands) | add)}' \
628
623
 
629
624
  ⚠️ **Important Security Notes:**
630
625
 
631
- 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.
627
+
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
632
633
 
633
- 2. **Access Control**: MCP clients that connect to CLI MCP Mapper will have the same permissions as the user running it.
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.
634
635
 
635
- 3. **Validate Parameters**: Use `enum` to restrict parameter values when possible.
636
+ 4. **Validate Parameters**: Use `enum` to restrict parameter values when possible to prevent unexpected inputs.
636
637
 
637
- 4. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
638
+ 5. **Avoid Dangerous Commands**: Be cautious about exposing commands like:
638
639
  - `rm -rf` without proper constraints
639
640
  - `chmod` with unrestricted modes
640
641
  - Commands that can execute arbitrary code
641
642
  - Network commands that could expose sensitive data
643
+ - System administration commands
642
644
 
643
- 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.
644
646
 
645
- 6. **Configuration Security**:
647
+ 7. **Configuration Security**:
646
648
  - Don't commit sensitive configurations to version control
647
649
  - Use environment-specific configuration files
648
650
  - Review configurations before deploying
651
+ - Regularly audit which commands are exposed
649
652
 
650
- 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.
651
654
 
652
655
  **Best Practices:**
653
656
 
@@ -702,4 +705,4 @@ MIT License - see [LICENSE](LICENSE) file for details.
702
705
 
703
706
  **Made with ❤️ for the MCP community**
704
707
 
705
- For issues, questions, or contributions, visit: https://github.com/SteffenBlake/cli-mcp-mapper
708
+ For issues, questions, or contributions, visit: https://github.com/SteffenBlake/cli-mcp-mapper
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,99 @@
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
+ reject(new Error(`Command failed with code ${code}\n${stderr}`));
90
+ } else {
91
+ resolve(stdout || stderr);
92
+ }
93
+ });
94
+
95
+ proc.on("error", (err) => {
96
+ reject(new Error(`Failed to execute command: ${err.message}`));
97
+ });
98
+ });
99
+ }
package/lib.test.js ADDED
@@ -0,0 +1,611 @@
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 reject when command fails', async () => {
564
+ const cmd = ['false']; // 'false' command always returns exit code 1
565
+
566
+ await expect(executeCommand(cmd)).rejects.toThrow('Command failed with code 1');
567
+ });
568
+
569
+ it('should reject when command does not exist', async () => {
570
+ const cmd = ['this-command-definitely-does-not-exist-12345'];
571
+
572
+ await expect(executeCommand(cmd)).rejects.toThrow('Failed to execute command');
573
+ });
574
+
575
+ it('should capture stderr on failure', async () => {
576
+ // Use a command that writes to stderr
577
+ const cmd = ['ls', '/this/path/does/not/exist/12345'];
578
+
579
+ await expect(executeCommand(cmd)).rejects.toThrow();
580
+ });
581
+ });
582
+
583
+ describe('Normal operation tests', () => {
584
+ it('should execute simple echo command', async () => {
585
+ const cmd = ['echo', 'hello world'];
586
+ const result = await executeCommand(cmd);
587
+
588
+ expect(result.trim()).toBe('hello world');
589
+ });
590
+
591
+ it('should execute command with multiple arguments', async () => {
592
+ const cmd = ['echo', 'arg1', 'arg2', 'arg3'];
593
+ const result = await executeCommand(cmd);
594
+
595
+ expect(result.trim()).toBe('arg1 arg2 arg3');
596
+ });
597
+
598
+ it('should handle arguments with spaces', async () => {
599
+ const cmd = ['echo', 'hello world', 'foo bar'];
600
+ const result = await executeCommand(cmd);
601
+
602
+ expect(result.trim()).toBe('hello world foo bar');
603
+ });
604
+
605
+ it('should handle arguments with special characters (non-shell)', async () => {
606
+ const cmd = ['echo', '@#$%^&*()'];
607
+ const result = await executeCommand(cmd);
608
+
609
+ expect(result.trim()).toBe('@#$%^&*()');
610
+ });
611
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-mcp-mapper",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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
  }