/ src / tests / shell-tool.test.ts
shell-tool.test.ts
  1  import { describe, test, expect, beforeEach } from "@jest/globals";
  2  import {
  3    initializeShellTool,
  4    handleShellTool,
  5    getApprovedCommands,
  6  } from "../tools/shell-tool.js";
  7  import { setAllowedDirectories } from "../utils/lib.js";
  8  import os from "os";
  9  
 10  describe("Shell Tool", () => {
 11    beforeEach(() => {
 12      // Initialize with some approved commands
 13      initializeShellTool(["ls", "pwd", "echo", "cat"]);
 14      setAllowedDirectories([os.tmpdir()]);
 15    });
 16  
 17    test("executes approved command", async () => {
 18      const result = await handleShellTool("execute_shell", {
 19        command: "echo test",
 20        description: "Test echo command",
 21        workdir: os.tmpdir(), // Specify workdir within allowed directory
 22      });
 23  
 24      expect(result.content).toHaveLength(1);
 25      expect(result.content[0].type).toBe("text");
 26      expect(result.content[0].text).toContain("test");
 27      expect(result.content[0].text).toContain("Exit Code: 0");
 28      expect(result.isError).toBe(false);
 29    }, 10000);
 30  
 31    test("rejects unapproved command without requiresApproval flag", async () => {
 32      await expect(
 33        handleShellTool("execute_shell", {
 34          command: "sudo apt install",
 35          requiresApproval: false,
 36        })
 37      ).rejects.toThrow("Command not in approved list");
 38    });
 39  
 40    test("rejects unapproved command with requiresApproval flag", async () => {
 41      await expect(
 42        handleShellTool("execute_shell", {
 43          command: "rm -rf /tmp/test",
 44          requiresApproval: true,
 45        })
 46      ).rejects.toThrow("Command not in approved list");
 47    });
 48  
 49    test("rejects dangerous command even if root is approved", async () => {
 50      await expect(
 51        handleShellTool("execute_shell", {
 52          command: "rm -rf /tmp/test",
 53          requiresApproval: false,
 54        })
 55      ).rejects.toThrow("Command not in approved list");
 56    });
 57  
 58    test("validates working directory", async () => {
 59      await expect(
 60        handleShellTool("execute_shell", {
 61          command: "echo test",
 62          workdir: "/unauthorized/path",
 63        })
 64      ).rejects.toThrow("Working directory is not within allowed directories");
 65    });
 66  
 67    test("respects timeout", async () => {
 68      // Add platform-specific commands to approved list for this test
 69      const sleepCmd = os.platform() === "win32" ? "Start-Sleep" : "sleep";
 70      initializeShellTool(["ls", "pwd", "echo", "cat", sleepCmd]);
 71  
 72      const command =
 73        os.platform() === "win32" ? "Start-Sleep -Seconds 5" : "sleep 5";
 74  
 75      const result = await handleShellTool("execute_shell", {
 76        command,
 77        timeout: 1000,
 78        workdir: os.tmpdir(), // Specify workdir within allowed directory
 79      });
 80  
 81      expect(result.content[0].text).toContain("TIMEOUT");
 82      expect(result.isError).toBe(true);
 83    }, 10000);
 84  
 85    test("includes command description in result", async () => {
 86      const result = await handleShellTool("execute_shell", {
 87        command: "echo test",
 88        description: "This is a test command",
 89        workdir: os.tmpdir(), // Specify workdir within allowed directory
 90      });
 91  
 92      expect(result.content[0].text).toContain("This is a test command");
 93    });
 94  
 95    test("reports non-zero exit codes as errors", async () => {
 96      // Add 'exit' to approved commands for this test
 97      initializeShellTool(["ls", "pwd", "echo", "cat", "exit"]);
 98      
 99      const command = os.platform() === "win32" ? "exit 1" : "exit 1";
100  
101      const result = await handleShellTool("execute_shell", {
102        command,
103        workdir: os.tmpdir(), // Specify workdir within allowed directory
104      });
105  
106      expect(result.content[0].text).toContain("Exit Code: 1");
107      expect(result.isError).toBe(true);
108    });
109  
110    test("rejects command with command substitution", async () => {
111      await expect(
112        handleShellTool("execute_shell", {
113          command: "echo $(whoami)",
114        })
115      ).rejects.toThrow("Command substitution");
116    });
117  
118    test("blocks command injection via approved commands (CVE-2025-54795 pattern)", async () => {
119      // This test verifies that command injection attempts through approved commands
120      // are blocked when unapproved commands are detected in the chain
121      // Pattern: echo test; rm -rf /tmp; echo done
122      // Even though echo is approved, the unapproved rm/del command should be blocked
123      if (os.platform() !== "win32") {
124        // On Unix, test with rm -rf (not in approved list)
125        const injectedCommand = "echo test; rm -rf /tmp/*; echo done";
126  
127        // Should be rejected because rm is not in approved list
128        await expect(
129          handleShellTool("execute_shell", {
130            command: injectedCommand,
131            requiresApproval: false,
132          })
133        ).rejects.toThrow("Command not in approved list");
134      } else {
135        // On Windows, test with del (not in approved list)
136        const injectedCommand = "echo test; del /s /q C:\\tmp\\*; echo done";
137  
138        // Should be rejected because del is not in approved list
139        await expect(
140          handleShellTool("execute_shell", {
141            command: injectedCommand,
142            requiresApproval: false,
143          })
144        ).rejects.toThrow("Command not in approved list");
145      }
146    });
147  
148    test("blocks unapproved commands when requiresApproval is true", async () => {
149      // This test verifies that unapproved commands are blocked regardless of requiresApproval flag
150      const injectedCommand = "echo test; unapproved_command; echo done";
151  
152      // Should be rejected because extractRootCommands will detect "unapproved_command"
153      // which is not in the approved list
154      await expect(
155        handleShellTool("execute_shell", {
156          command: injectedCommand,
157          requiresApproval: true,
158        })
159      ).rejects.toThrow("Command not in approved list");
160    });
161  
162    test("rejects empty command", async () => {
163      await expect(
164        handleShellTool("execute_shell", {
165          command: "",
166        })
167      ).rejects.toThrow();
168    });
169  
170    test("throws error for unknown tool name", async () => {
171      await expect(
172        handleShellTool("unknown_tool", {
173          command: "echo test",
174        })
175      ).rejects.toThrow("Unknown shell tool");
176    });
177  
178    test("getApprovedCommands returns initialized commands", () => {
179      const commands = getApprovedCommands();
180      expect(commands).toContain("ls");
181      expect(commands).toContain("pwd");
182      expect(commands).toContain("echo");
183      expect(commands).toContain("cat");
184    });
185  
186    test("executes command in specified working directory", async () => {
187      const testDir = os.tmpdir();
188      const command = os.platform() === "win32" ? "pwd" : "pwd";
189  
190      const result = await handleShellTool("execute_shell", {
191        command,
192        workdir: testDir,
193      });
194  
195      expect(result.content[0].text).toContain(testDir);
196    });
197  
198    test("captures both stdout and stderr", async () => {
199      // Add Write-Error to approved commands for Windows test
200      if (os.platform() === "win32") {
201        initializeShellTool(["ls", "pwd", "echo", "cat", "Write-Error"]);
202      }
203      
204      const command =
205        os.platform() === "win32"
206          ? "echo stdout; Write-Error 'stderr'"
207          : "echo stdout && echo stderr >&2";
208  
209      const result = await handleShellTool("execute_shell", {
210        command,
211        workdir: os.tmpdir(), // Specify workdir within allowed directory
212      });
213  
214      expect(result.content[0].text).toContain("Standard Output");
215      expect(result.content[0].text).toContain("Standard Error");
216    });
217  
218    test("handles chained commands when all roots approved", async () => {
219      const command =
220        os.platform() === "win32"
221          ? "echo first; echo second"
222          : "echo first && echo second";
223  
224      const result = await handleShellTool("execute_shell", {
225        command,
226        workdir: os.tmpdir(), // Specify workdir within allowed directory
227      });
228  
229      expect(result.content[0].text).toContain("first");
230      expect(result.content[0].text).toContain("second");
231      expect(result.isError).toBe(false);
232    });
233  
234    test("rejects chained commands when any root not approved", async () => {
235      const command =
236        os.platform() === "win32"
237          ? "echo test; sudo apt install"
238          : "echo test && sudo apt install";
239  
240      await expect(
241        handleShellTool("execute_shell", {
242          command,
243        })
244      ).rejects.toThrow("Command not in approved list");
245    });
246  
247    test("includes working directory in result", async () => {
248      const result = await handleShellTool("execute_shell", {
249        command: "echo test",
250        workdir: os.tmpdir(), // Specify workdir within allowed directory
251      });
252  
253      expect(result.content[0].text).toContain("Working Directory:");
254    });
255  
256    test("includes command in result", async () => {
257      const result = await handleShellTool("execute_shell", {
258        command: "echo hello world",
259        workdir: os.tmpdir(), // Specify workdir within allowed directory
260      });
261  
262      expect(result.content[0].text).toContain("Command: echo hello world");
263    });
264  
265    test("formats result with proper sections", async () => {
266      const result = await handleShellTool("execute_shell", {
267        command: "echo test",
268        workdir: os.tmpdir(), // Specify workdir within allowed directory
269      });
270  
271      const text = result.content[0].text;
272      expect(text).toContain("Shell Command Execution Result:");
273      expect(text).toContain("--- Standard Output ---");
274      expect(text).toContain("--- Standard Error ---");
275      expect(text).toContain("Exit Code:");
276    });
277  
278    // Security Fix Tests: Shell Execution Directory Bypass Vulnerability
279    describe("Security Fix: Directory Validation", () => {
280      test("rejects shell execution when no approved directories configured", async () => {
281        // Setup: Clear all allowed directories to simulate no configuration
282        setAllowedDirectories([]);
283  
284        await expect(
285          handleShellTool("execute_shell", {
286            command: "echo test",
287            description: "Test command without approved directories",
288          })
289        ).rejects.toThrow("at least one approved directory");
290      });
291  
292      test("validates process.cwd() when workdir not provided", async () => {
293        // Setup: Set allowed directory to something that's NOT process.cwd()
294        // Use a very specific path that won't match current working directory
295        const nonMatchingDir = os.platform() === "win32" 
296          ? "C:\\NonExistentSecureDirectory123456" 
297          : "/nonexistent-secure-directory-123456";
298        
299        setAllowedDirectories([nonMatchingDir]);
300  
301        // When workdir is NOT provided, process.cwd() should be validated
302        // and rejected since it's not in the allowed directories
303        await expect(
304          handleShellTool("execute_shell", {
305            command: "echo test",
306            description: "Test without workdir parameter",
307            // No workdir provided - should validate process.cwd()
308          })
309        ).rejects.toThrow("Working directory is not within allowed directories");
310      });
311  
312      test("accepts command with workdir in approved directory", async () => {
313        // Setup: Approved directory
314        const approvedDir = os.tmpdir();
315        setAllowedDirectories([approvedDir]);
316  
317        const result = await handleShellTool("execute_shell", {
318          command: "echo test",
319          workdir: approvedDir,
320          description: "Test with approved workdir",
321        });
322  
323        expect(result.isError).toBe(false);
324        expect(result.content[0].text).toContain("Exit Code: 0");
325      });
326  
327      test("rejects command with workdir outside approved directories", async () => {
328        // Setup: Approved directory
329        setAllowedDirectories([os.tmpdir()]);
330  
331        // Try to execute in unauthorized directory
332        const unauthorizedDir = os.platform() === "win32" 
333          ? "C:\\Windows\\System32" 
334          : "/etc";
335  
336        await expect(
337          handleShellTool("execute_shell", {
338            command: "echo test",
339            workdir: unauthorizedDir,
340            description: "Test with unapproved workdir",
341          })
342        ).rejects.toThrow("Working directory is not within allowed directories");
343      });
344  
345      test("provides helpful error message when directory not approved", async () => {
346        setAllowedDirectories([os.tmpdir()]);
347        
348        const unauthorizedDir = os.platform() === "win32" 
349          ? "C:\\Windows" 
350          : "/usr";
351  
352        try {
353          await handleShellTool("execute_shell", {
354            command: "echo test",
355            workdir: unauthorizedDir,
356          });
357          // Should not reach here
358          expect(true).toBe(false);
359        } catch (error) {
360          const errorMessage = (error as Error).message;
361          
362          // Verify error message includes helpful information
363          expect(errorMessage).toContain("Access denied");
364          expect(errorMessage).toContain("Allowed directories:");
365          expect(errorMessage).toContain(os.tmpdir());
366          expect(errorMessage).toContain("register_directory");
367        }
368      });
369  
370      test("provides helpful error message when no directories configured", async () => {
371        setAllowedDirectories([]);
372  
373        try {
374          await handleShellTool("execute_shell", {
375            command: "echo test",
376          });
377          // Should not reach here
378          expect(true).toBe(false);
379        } catch (error) {
380          const errorMessage = (error as Error).message;
381          
382          // Verify error message includes helpful guidance
383          expect(errorMessage).toContain("Access denied");
384          expect(errorMessage).toContain("at least one approved directory");
385          expect(errorMessage).toContain("--approved-folders");
386          expect(errorMessage).toContain("register_directory");
387        }
388      });
389  
390      test("CVE Fix: prevents arbitrary execution via process.cwd() bypass", async () => {
391        // This test specifically validates the CVE fix
392        // Previously, omitting workdir would bypass directory validation
393        
394        // Setup: Configure specific allowed directory
395        const allowedDir = os.tmpdir();
396        setAllowedDirectories([allowedDir]);
397  
398        // Scenario 1: Command with explicit workdir in allowed directory - should work
399        const result1 = await handleShellTool("execute_shell", {
400          command: "echo allowed",
401          workdir: allowedDir,
402        });
403        expect(result1.isError).toBe(false);
404  
405        // Scenario 2: Command without workdir - should validate process.cwd()
406        // If process.cwd() is not in allowed directories, it should fail
407        // Note: This test might pass or fail depending on where tests run from
408        // The important thing is that validation HAPPENS, not that it's blocked
409        try {
410          await handleShellTool("execute_shell", {
411            command: "echo test",
412            // No workdir - will use and validate process.cwd()
413          });
414          
415          // If we reach here, process.cwd() must be within allowed directories
416          // which is valid behavior - the key is that validation occurred
417        } catch (error) {
418          // If we catch an error, it should be about directory validation
419          const errorMessage = (error as Error).message;
420          expect(errorMessage).toContain("Working directory is not within allowed directories");
421        }
422      });
423    });
424  
425    // Security Fix Tests: Command Approval Bypass Vulnerability
426    describe("Security Fix: Strict Command Whitelist Enforcement", () => {
427      beforeEach(() => {
428        // Initialize with approved commands
429        initializeShellTool(["ls", "pwd", "echo", "cat"]);
430        setAllowedDirectories([os.tmpdir()]);
431      });
432  
433      test("blocks unapproved non-dangerous command (whoami)", async () => {
434        await expect(
435          handleShellTool("execute_shell", {
436            command: "whoami",
437            workdir: os.tmpdir(),
438          })
439        ).rejects.toThrow("Command not in approved list");
440      });
441  
442      test("blocks unapproved non-dangerous command (hostname)", async () => {
443        await expect(
444          handleShellTool("execute_shell", {
445            command: "hostname",
446            workdir: os.tmpdir(),
447          })
448        ).rejects.toThrow("Command not in approved list");
449      });
450  
451      test("blocks Windows dir command (not in approved list)", async () => {
452        if (os.platform() === "win32") {
453          await expect(
454            handleShellTool("execute_shell", {
455              command: "dir",
456              workdir: os.tmpdir(),
457            })
458          ).rejects.toThrow("Command not in approved list");
459        }
460      });
461  
462      test("blocks Windows type command (not in approved list)", async () => {
463        if (os.platform() === "win32") {
464          await expect(
465            handleShellTool("execute_shell", {
466              command: "type test.txt",
467              workdir: os.tmpdir(),
468            })
469          ).rejects.toThrow("Command not in approved list");
470        }
471      });
472  
473      test("blocks Windows copy command (not in approved list)", async () => {
474        if (os.platform() === "win32") {
475          await expect(
476            handleShellTool("execute_shell", {
477              command: "copy file1.txt file2.txt",
478              workdir: os.tmpdir(),
479            })
480          ).rejects.toThrow("Command not in approved list");
481        }
482      });
483  
484      test("blocks Windows move command (not in approved list)", async () => {
485        if (os.platform() === "win32") {
486          await expect(
487            handleShellTool("execute_shell", {
488              command: "move file1.txt file2.txt",
489              workdir: os.tmpdir(),
490            })
491          ).rejects.toThrow("Command not in approved list");
492        }
493      });
494  
495      test("blocks Windows ren command (not in approved list)", async () => {
496        if (os.platform() === "win32") {
497          await expect(
498            handleShellTool("execute_shell", {
499              command: "ren file1.txt file2.txt",
500              workdir: os.tmpdir(),
501            })
502          ).rejects.toThrow("Command not in approved list");
503        }
504      });
505  
506      test("blocks Windows del command without /s flag (not in approved list)", async () => {
507        if (os.platform() === "win32") {
508          await expect(
509            handleShellTool("execute_shell", {
510              command: "del test.txt",
511              workdir: os.tmpdir(),
512            })
513          ).rejects.toThrow("Command not in approved list");
514        }
515      });
516  
517      test("blocks Windows mkdir command (not in approved list)", async () => {
518        if (os.platform() === "win32") {
519          await expect(
520            handleShellTool("execute_shell", {
521              command: "mkdir newdir",
522              workdir: os.tmpdir(),
523            })
524          ).rejects.toThrow("Command not in approved list");
525        }
526      });
527  
528      test("blocks Windows rmdir command (not in approved list)", async () => {
529        if (os.platform() === "win32") {
530          await expect(
531            handleShellTool("execute_shell", {
532              command: "rmdir emptydir",
533              workdir: os.tmpdir(),
534            })
535          ).rejects.toThrow("Command not in approved list");
536        }
537      });
538  
539      test("blocks Windows ipconfig command (not in approved list)", async () => {
540        if (os.platform() === "win32") {
541          await expect(
542            handleShellTool("execute_shell", {
543              command: "ipconfig",
544              workdir: os.tmpdir(),
545            })
546          ).rejects.toThrow("Command not in approved list");
547        }
548      });
549  
550      test("allows approved commands to execute", async () => {
551        const result = await handleShellTool("execute_shell", {
552          command: "echo approved",
553          workdir: os.tmpdir(),
554        });
555  
556        expect(result.isError).toBe(false);
557        expect(result.content[0].text).toContain("approved");
558      });
559  
560      test("allows multiple approved commands in chain", async () => {
561        const command =
562          os.platform() === "win32"
563            ? "echo first; echo second"
564            : "echo first && echo second";
565  
566        const result = await handleShellTool("execute_shell", {
567          command,
568          workdir: os.tmpdir(),
569        });
570  
571        expect(result.isError).toBe(false);
572        expect(result.content[0].text).toContain("first");
573      });
574  
575      test("error message includes list of approved commands", async () => {
576        try {
577          await handleShellTool("execute_shell", {
578            command: "whoami",
579            workdir: os.tmpdir(),
580          });
581          fail("Should have thrown error");
582        } catch (error) {
583          const errorMessage = (error as Error).message;
584          expect(errorMessage).toContain("Approved commands:");
585          expect(errorMessage).toContain("ls");
586          expect(errorMessage).toContain("pwd");
587          expect(errorMessage).toContain("echo");
588          expect(errorMessage).toContain("cat");
589        }
590      });
591  
592      test("error message identifies specific unapproved command", async () => {
593        try {
594          await handleShellTool("execute_shell", {
595            command: "whoami",
596            workdir: os.tmpdir(),
597          });
598          fail("Should have thrown error");
599        } catch (error) {
600          const errorMessage = (error as Error).message;
601          expect(errorMessage).toContain("Unapproved commands: whoami");
602          expect(errorMessage).toContain("Access denied");
603        }
604      });
605  
606      test("blocks unapproved command even with requiresApproval=false", async () => {
607        // This is the critical bug fix test
608        // Previously, requiresApproval=false would allow execution
609        await expect(
610          handleShellTool("execute_shell", {
611            command: "whoami",
612            requiresApproval: false,
613            workdir: os.tmpdir(),
614          })
615        ).rejects.toThrow("Command not in approved list");
616      });
617  
618      test("blocks dangerous pattern on approved command without explicit approval", async () => {
619        // Add 'rm' to approved commands
620        initializeShellTool(["ls", "pwd", "echo", "cat", "rm"]);
621  
622        // 'rm' is approved, but 'rm -rf' is dangerous and requires explicit approval
623        await expect(
624          handleShellTool("execute_shell", {
625            command: "rm -rf /tmp/test",
626            requiresApproval: false, // No explicit approval
627            workdir: os.tmpdir(),
628          })
629        ).rejects.toThrow("Dangerous command pattern detected");
630      });
631  
632      test("allows dangerous pattern on approved command with explicit approval", async () => {
633        // Add platform-specific delete command to approved commands
634        const deleteCmd = os.platform() === "win32" ? "del" : "rm";
635        initializeShellTool(["ls", "pwd", "echo", "cat", deleteCmd]);
636  
637        // Create a test file to delete
638        const testFile = `${os.tmpdir()}/test-delete-${Date.now()}.txt`;
639        require("fs").writeFileSync(testFile, "test");
640  
641        // Delete command is approved and requiresApproval=true, should work
642        const command = os.platform() === "win32" ? `del ${testFile}` : `rm ${testFile}`;
643        const result = await handleShellTool("execute_shell", {
644          command,
645          requiresApproval: true,
646          workdir: os.tmpdir(),
647        });
648  
649        // Command is approved with explicit approval, should succeed
650        expect(result.isError).toBe(false);
651      });
652  
653      test("regression: all examples from RCA should now be blocked", async () => {
654        // Reset to the exact approved list from the RCA
655        initializeShellTool(["npm", "node", "git", "ls", "pwd", "cat", "echo"]);
656  
657        const unapprovedCommands = [
658          "whoami",
659          "hostname",
660          ...(os.platform() === "win32"
661            ? [
662                "dir",
663                "type test.txt",
664                "copy a.txt b.txt",
665                "move a.txt b.txt",
666                "ren a.txt b.txt",
667                "del test.txt",
668                "mkdir newdir",
669                "rmdir olddir",
670                "ipconfig",
671              ]
672            : []),
673        ];
674  
675        for (const cmd of unapprovedCommands) {
676          await expect(
677            handleShellTool("execute_shell", {
678              command: cmd,
679              workdir: os.tmpdir(),
680            })
681          ).rejects.toThrow("Command not in approved list");
682        }
683      });
684    });
685  });