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 });