diff --git a/scripts/wsl-emulator.js b/scripts/wsl-emulator.js index d6b0dde..491c0ce 100644 --- a/scripts/wsl-emulator.js +++ b/scripts/wsl-emulator.js @@ -1,122 +1,172 @@ #!/usr/bin/env node -// WSL Emulator for cross-platform testing -// Mimics basic wsl.exe behavior for development and testing +// WSL emulator for cross-platform tests. +// Supports: +// - `-l -v` / `--list --verbose` +// - `-e [args...]` -import { spawnSync } from 'child_process'; -import path from 'path'; import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'child_process'; const args = process.argv.slice(2); -// Mock file system for 'ls' command emulation -const mockFileSystem = { - '/tmp': [ - // Mimicking 'ls -la /tmp' output structure for simplicity, even if not all details are used by tests - 'total 0', - 'drwxrwxrwt 2 root root 40 Jan 1 00:00 .', - 'drwxr-xr-x 20 root root 480 Jan 1 00:00 ..' - // Add more mock files/dirs for /tmp if needed by other tests, e.g., 'somefile.txt' - ], - // Example: Add other mock paths as needed by tests - // '/mnt/c/Users/testuser/docs': ['document1.txt', 'report.pdf'], -}; - - -// Handle WSL list distributions command -if ((args.includes('-l') || args.includes('--list')) && - (args.includes('-v') || args.includes('--verbose'))) { +function normalizePosixPath(inputPath) { + const normalized = path.posix.normalize(inputPath); + if (normalized === '/') { + return normalized; + } + return normalized.replace(/\/+$/, ''); +} + +function isPathInAllowedPaths(testPath, allowedPaths) { + const normalizedTestPath = normalizePosixPath(testPath); + + return allowedPaths.some((allowedPath) => { + const normalizedAllowedPath = normalizePosixPath(allowedPath); + const relativePath = path.posix.relative(normalizedAllowedPath, normalizedTestPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.posix.isAbsolute(relativePath)); + }); +} + +function parseAllowedPaths(rawValue) { + if (!rawValue) { + return []; + } + + const trimmed = rawValue.trim(); + if (!trimmed) { + return []; + } + + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.filter((value) => typeof value === 'string' && value.startsWith('/')); + } + } catch { + // Fall through to delimiter parsing + } + + return trimmed + .split(/[;,]/) + .map((value) => value.trim()) + .filter((value) => value.startsWith('/')); +} + +function validateWorkingDirFromEnv() { + const allowedPathsRaw = process.env.WSL_ALLOWED_PATHS || process.env.ALLOWED_PATHS || ''; + const allowedPaths = parseAllowedPaths(allowedPathsRaw); + + if (allowedPaths.length === 0) { + return; + } + + const workingDir = process.env.WSL_ORIGINAL_PATH || process.cwd(); + if (!workingDir.startsWith('/') || !isPathInAllowedPaths(workingDir, allowedPaths)) { + console.error(`WSL working directory is not allowed: ${workingDir}`); + process.exit(1); + } +} + +if ((args.includes('-l') || args.includes('--list')) && (args.includes('-v') || args.includes('--verbose'))) { console.log('NAME STATE VERSION'); console.log('* Ubuntu-Test Running 2'); process.exit(0); } -// Handle command execution with -e flag -if (args[0] === '-e') { - if (args.length < 2) { - console.error('Error: No command provided after -e flag.'); - console.error('Usage: wsl-emulator -e '); - process.exit(1); +if (args[0] !== '-e' || args.length < 2) { + console.error('Error: Invalid arguments. Expected -e [args...] OR --list --verbose'); + process.exit(1); +} + +validateWorkingDirFromEnv(); + +const command = args[1]; +const commandArgs = args.slice(2); +const emulatedWorkingDir = process.env.WSL_ORIGINAL_PATH || process.cwd(); + +switch (command) { + case 'pwd': + console.log(emulatedWorkingDir); + process.exit(0); + break; + case 'echo': + console.log(commandArgs.join(' ')); + process.exit(0); + break; + case 'exit': { + const exitCode = commandArgs.length === 1 ? Number.parseInt(commandArgs[0], 10) : 0; + process.exit(Number.isNaN(exitCode) ? 0 : exitCode); + break; } + case 'uname': + if (commandArgs.length > 0 && commandArgs[0] === '-a') { + console.log('Linux Ubuntu-Test 5.15.0-0-generic x86_64 GNU/Linux'); + } else { + console.log('Linux'); + } + process.exit(0); + break; + case 'ls': { + const resolvedArgs = commandArgs.length > 0 ? commandArgs : [emulatedWorkingDir]; + const hasAllFlag = resolvedArgs.some((arg) => arg.startsWith('-') && arg.includes('a')); + const explicitTmp = resolvedArgs.includes('/tmp'); - // Get the command and its arguments - const command = args[1]; - const commandArgs = args.slice(2); - - // Special handling for common test commands - switch (command) { - case 'pwd': - // Use original WSL path if available (when called from server) - if (process.env.WSL_ORIGINAL_PATH) { - console.log(process.env.WSL_ORIGINAL_PATH); - } else { - // When called directly (like in standalone tests), return actual directory - console.log(process.cwd()); - } + if (hasAllFlag && explicitTmp) { + console.log('total 8'); + console.log('drwxrwxrwt 10 root root 4096 Jan 1 00:00 .'); + console.log('drwxr-xr-x 23 root root 4096 Jan 1 00:00 ..'); process.exit(0); break; - case 'exit': - if (commandArgs.length === 1) { - const exitCode = parseInt(commandArgs[0], 10); - if (!isNaN(exitCode)) { - process.exit(exitCode); - } + } + + const lsResult = spawnSync('ls', resolvedArgs, { + cwd: process.cwd(), + env: process.env, + encoding: 'utf8' + }); + + if (!lsResult.error) { + if (lsResult.stdout) { + process.stdout.write(lsResult.stdout); } - process.exit(0); - break; - case 'ls': - const pathArg = commandArgs.find(arg => arg.startsWith('/')); - - if (pathArg) { - // Path argument provided, use mockFileSystem - if (mockFileSystem.hasOwnProperty(pathArg)) { - mockFileSystem[pathArg].forEach(item => console.log(item)); - process.exit(0); - } else { - console.error(`ls: cannot access '${pathArg}': No such file or directory`); - process.exit(2); - } - } else { - // No path argument, list contents of process.cwd() - try { - const files = fs.readdirSync(process.cwd()); - files.forEach(file => { - console.log(file); // Test 5.1.1 expects to find 'src' - }); - process.exit(0); - } catch (e) { - console.error(`ls: cannot read directory '.': ${e.message}`); - process.exit(2); - } + if (lsResult.stderr) { + process.stderr.write(lsResult.stderr); } + process.exit(typeof lsResult.status === 'number' ? lsResult.status : 0); break; - case 'echo': - console.log(commandArgs.join(' ')); + } + + const targetPath = commandArgs.find((arg) => !arg.startsWith('-')) || emulatedWorkingDir; + try { + const entries = fs.readdirSync(targetPath); + entries.forEach((entry) => console.log(entry)); process.exit(0); - break; - case 'uname': - if (commandArgs.includes('-a')) { - console.log('Linux Ubuntu-Test 5.10.0-0-generic #0-Ubuntu SMP x86_64 GNU/Linux'); - process.exit(0); - } - break; + } catch { + console.error(`ls: cannot access '${targetPath}': No such file or directory`); + process.exit(2); + } + break; } - - // For other commands, try to execute them - try { + default: { const result = spawnSync(command, commandArgs, { - stdio: 'inherit', - shell: true, - env: { ...process.env, WSL_DISTRO_NAME: 'Ubuntu-Test' } + cwd: process.cwd(), + env: process.env, + encoding: 'utf8' }); - process.exit(result.status || 0); - } catch (error) { - console.error(`Command execution failed: ${error.message}`); - process.exit(127); + + if (result.error) { + console.error(result.error.message); + process.exit(typeof result.status === 'number' ? result.status : 1); + } + + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.exit(typeof result.status === 'number' ? result.status : 0); } } - -// If no recognized command, show error -console.error('Error: Invalid arguments. Expected -e OR --list --verbose'); -console.error('Received:', args.join(' ')); -process.exit(1); diff --git a/src/index.ts b/src/index.ts index b4d9a7c..120d653 100644 --- a/src/index.ts +++ b/src/index.ts @@ -385,7 +385,7 @@ class CLIServer { let shellProcess: ReturnType; let spawnArgs: string[]; - if (shellConfig.type === 'wsl' || shellConfig.type === 'bash') { + if (shellConfig.type === 'wsl') { const parsedCommand = parseCommand(command); spawnArgs = [...shellConfig.executable.args, parsedCommand.command, ...parsedCommand.args]; } else { @@ -396,7 +396,7 @@ class CLIServer { // For WSL, convert WSL paths back to Windows paths for spawn cwd let spawnCwd = workingDir; let envVars = { ...process.env }; - if (shellConfig.type === 'wsl' || shellConfig.type === 'bash') { + if (shellConfig.type === 'wsl') { if (workingDir.startsWith('/mnt/')) { // Convert /mnt/c/path to C:\path const match = workingDir.match(/^\/mnt\/([a-z])\/(.*)$/i); diff --git a/src/utils/validationContext.ts b/src/utils/validationContext.ts index b61afb2..8144c24 100644 --- a/src/utils/validationContext.ts +++ b/src/utils/validationContext.ts @@ -20,7 +20,7 @@ export function createValidationContext( ): ValidationContext { const isWindowsShell = shellConfig.type === 'cmd' || shellConfig.type === 'powershell'; const isUnixShell = shellConfig.type === 'gitbash' || shellConfig.type === 'wsl' || shellConfig.type === 'bash'; - const isWslShell = shellConfig.type === 'wsl' || shellConfig.type === 'bash'; + const isWslShell = shellConfig.type === 'wsl'; return { shellName, diff --git a/tests/bash/bashShell.test.ts b/tests/bash/bashShell.test.ts index 7d141da..ec036ea 100644 --- a/tests/bash/bashShell.test.ts +++ b/tests/bash/bashShell.test.ts @@ -3,6 +3,8 @@ import { CLIServer } from '../../src/index.js'; import { DEFAULT_CONFIG } from '../../src/utils/config.js'; import type { ServerConfig } from '../../src/types/config.js'; +const describeWithBash = process.platform === 'win32' ? describe.skip : describe; + let server: CLIServer; let config: ServerConfig; @@ -30,7 +32,7 @@ beforeEach(() => { server = new CLIServer(config); }); -describe('Bash shell basic execution', () => { +describeWithBash('Bash shell basic execution', () => { test('echo command', async () => { const result = await server._executeTool({ name: 'execute_command', diff --git a/tests/helpers/TestCLIServer.ts b/tests/helpers/TestCLIServer.ts index 3669e86..fe6b1c5 100644 --- a/tests/helpers/TestCLIServer.ts +++ b/tests/helpers/TestCLIServer.ts @@ -63,6 +63,48 @@ export class TestCLIServer { (baseConfig.global.restrictions.blockedArguments || []).filter(a => a !== '-e'); } + // Merge shell overrides deeply so partial test overrides don't drop required defaults. + const mergedShells: ServerConfig['shells'] = { ...(baseConfig.shells || {}) }; + for (const [shellName, shellOverride] of Object.entries(overrides.shells || {})) { + const key = shellName as keyof ServerConfig['shells']; + const baseShell = mergedShells[key] as any; + const overrideShell = shellOverride as any; + + if (!baseShell) { + (mergedShells as any)[key] = overrideShell; + continue; + } + + (mergedShells as any)[key] = { + ...baseShell, + ...overrideShell, + executable: { + ...(baseShell.executable || {}), + ...(overrideShell.executable || {}) + }, + overrides: { + ...(baseShell.overrides || {}), + ...(overrideShell.overrides || {}), + security: { + ...(baseShell.overrides?.security || {}), + ...(overrideShell.overrides?.security || {}) + }, + paths: { + ...(baseShell.overrides?.paths || {}), + ...(overrideShell.overrides?.paths || {}) + }, + restrictions: { + ...(baseShell.overrides?.restrictions || {}), + ...(overrideShell.overrides?.restrictions || {}) + } + }, + wslConfig: { + ...(baseShell.wslConfig || {}), + ...(overrideShell.wslConfig || {}) + } + }; + } + // Merge overrides deeply const config: ServerConfig = { ...baseConfig, @@ -82,11 +124,7 @@ export class TestCLIServer { ...(overrides.global?.restrictions || {}) } }, - shells: { - ...baseConfig.shells, - ...(overrides.shells || {}), - wsl: overrides.shells?.wsl ? { ...wslShell, ...overrides.shells.wsl } : wslShell - } + shells: mergedShells } as ServerConfig; this.server = new CLIServer(config); diff --git a/tests/integration/macosBashAuto.test.ts b/tests/integration/macosBashAuto.test.ts new file mode 100644 index 0000000..afb053c --- /dev/null +++ b/tests/integration/macosBashAuto.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { TestCLIServer } from '../helpers/TestCLIServer.js'; + +const describeMacOnly = process.platform === 'darwin' ? describe : describe.skip; + +describeMacOnly('macOS Bash Integration', () => { + let server: TestCLIServer; + + beforeAll(async () => { + server = new TestCLIServer({ + global: { + paths: { allowedPaths: ['/tmp'] } + }, + shells: { + bash: { type: 'bash', enabled: true } + } + }); + }); + + afterAll(async () => { + // Note: TestCLIServer doesn't have explicit cleanup method + // No cleanup needed as we're using bash shell + }); + + describe('Unix Path Validation', () => { + test('accepts /tmp paths', async () => { + const result = await server.executeCommand({ + shell: 'bash', + command: 'pwd', + workingDir: '/tmp' + }); + expect(result.exitCode).toBe(0); + expect(result.content.trim()).toMatch(/^\/(private\/)?tmp$/); + }); + + test('rejects Windows paths', async () => { + const result = await server.executeCommand({ + shell: 'bash', + command: 'cd C:\\Users', + workingDir: '/tmp' + }); + expect(result.exitCode).not.toBe(0); + }); + }); + + describe('macOS Command Blocking', () => { + test('blocks dangerous commands', async () => { + await expect( + server.executeCommand({ + shell: 'bash', + command: 'shutdown now', + workingDir: '/tmp' + }) + ).rejects.toThrow(/blocked/i); + }); + + test('allows safe commands', async () => { + const result = await server.executeCommand({ + shell: 'bash', + command: 'ls /tmp', + workingDir: '/tmp' + }); + expect(result.exitCode).toBe(0); + }); + }); + + describe('Working Directory Restrictions', () => { + test('rejects commands outside allowed paths', async () => { + await expect( + server.executeCommand({ + shell: 'bash', + command: 'pwd', + workingDir: '/private' + }) + ).rejects.toThrow(/validation failed|not allowed|allowed paths/i); + }); + + test('allows commands in allowed paths', async () => { + const result = await server.executeCommand({ + shell: 'bash', + command: 'pwd', + workingDir: '/tmp' + }); + expect(result.exitCode).toBe(0); + }); + }); +}); diff --git a/tests/wsl.test.ts b/tests/wsl.test.ts index 4e8a72d..d3fb9c0 100644 --- a/tests/wsl.test.ts +++ b/tests/wsl.test.ts @@ -262,18 +262,19 @@ describe('WSL Working Directory Validation (Test 5)', () => { // Removed .only name: 'execute_command', arguments: { shell: 'wsl', - command: 'ls', // Simple command + command: 'pwd', workingDir: wslTmpPath } }) as CallToolResult; expect(result.isError).toBe(false); expect((result.metadata as any)?.exitCode).toBe(0); - // `ls` in the emulator will output the contents of the provided - // working directory. We simply ensure some output was produced and - // that the metadata reflects the directory used. - expect(result.content[0].text).not.toBe(''); - expect(result.content[0].text).not.toContain('Executed successfully'); // No longer part of eval output + const firstContent = result.content[0]; + if (firstContent && firstContent.type === 'text') { + expect(firstContent.text.trim()).toBe(wslTmpPath); + } else { + throw new Error('Expected first content part to be text for pwd test.'); + } expect((result.metadata as any)?.workingDirectory).toBe(wslTmpPath); }); diff --git a/tests/wsl/isWslPathAllowed.test.ts b/tests/wsl/isWslPathAllowed.test.ts index 20a4de2..be5bf05 100644 --- a/tests/wsl/isWslPathAllowed.test.ts +++ b/tests/wsl/isWslPathAllowed.test.ts @@ -7,6 +7,8 @@ describe('isWslPathAllowed', () => { test.each([ ['/mnt/c/allowed/subdir', true], ['/tmp/workdir', true], + ['/tmp/tad/sub', true], + ['/tmp2/tad/sub', false], ['/mnt/c/Windows/allowed/test', true], ['/mnt/d/forbidden', false], ['/usr/local', false],