Skip to main content
Glama
blade47

ShadowGit MCP Server

by blade47
git-executor.test.ts14.9 kB
import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { GitExecutor } from '../../src/core/git-executor'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; // Mock the dependencies jest.mock('child_process'); jest.mock('fs'); jest.mock('../../src/utils/logger', () => ({ log: jest.fn(), })); describe('GitExecutor', () => { let executor: GitExecutor; let mockExecFileSync: jest.MockedFunction<typeof execFileSync>; let mockExistsSync: jest.MockedFunction<typeof fs.existsSync>; beforeEach(() => { jest.clearAllMocks(); executor = new GitExecutor(); mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>; mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>; mockExistsSync.mockReturnValue(true); // Default: .shadowgit.git exists }); describe('execute', () => { describe('Security Validation', () => { it('should block write commands when isInternal is false', async () => { const dangerousCommands = [ 'commit -m "test"', 'push origin main', 'pull origin main', 'merge feature-branch', 'rebase main', 'reset --hard HEAD~1', 'clean -fd', 'checkout -b new-branch', 'add .', 'rm file.txt', 'mv old.txt new.txt', ]; for (const cmd of dangerousCommands) { const result = await executor.execute(cmd, '/test/repo', false); const gitCommand = cmd.split(' ')[0]; expect(result).toContain(`Error: Command '${gitCommand}' is not allowed`); expect(result).toContain('Only read-only commands are permitted'); expect(mockExecFileSync).not.toHaveBeenCalled(); } }); it('should allow write commands when isInternal is true', async () => { mockExecFileSync.mockReturnValue('Success'); const commands = ['commit -m "test"', 'add .', 'push origin main']; for (const cmd of commands) { jest.clearAllMocks(); const result = await executor.execute(cmd, '/test/repo', true); expect(result).not.toContain('Error: Command not allowed'); expect(mockExecFileSync).toHaveBeenCalled(); } }); it('should block dangerous arguments even in read commands', async () => { const dangerousArgs = [ 'log --exec=rm -rf /', 'diff --upload-pack=evil', 'show --receive-pack=bad', 'log -e rm', // -e followed by space ]; for (const cmd of dangerousArgs) { const result = await executor.execute(cmd, '/test/repo', false); expect(result).toContain('Error: Command contains potentially dangerous arguments'); expect(mockExecFileSync).not.toHaveBeenCalled(); } // -c flag should now be blocked const blockedConfigArgs = [ 'log -c core.editor=vim', 'diff --config user.name=evil', ]; for (const cmd of blockedConfigArgs) { jest.clearAllMocks(); const result = await executor.execute(cmd, '/test/repo', false); expect(result).toContain('Error: Command contains potentially dangerous arguments'); expect(mockExecFileSync).not.toHaveBeenCalled(); } }); it('should allow safe read-only commands', async () => { const safeCommands = [ 'log --oneline -5', 'diff HEAD~1 HEAD', 'show abc123', 'blame file.txt', 'status', 'rev-parse HEAD', 'ls-files', 'cat-file -p HEAD', 'describe --tags', ]; mockExecFileSync.mockReturnValue('output'); for (const cmd of safeCommands) { jest.clearAllMocks(); const result = await executor.execute(cmd, '/test/repo', false); expect(result).not.toContain('Error'); expect(result).toBe('output'); expect(mockExecFileSync).toHaveBeenCalled(); } }); it('should detect command injection attempts', async () => { const injectionAttempts = [ '; rm -rf /', '&& malicious-command', '| evil-pipe', '& background-job', '|| fallback-command', '$(dangerous-subshell)', '`backtick-execution`', ]; for (const cmd of injectionAttempts) { const result = await executor.execute(cmd, '/test/repo', false); // These shell operators become the command name const firstToken = cmd.trim().split(/\s+/)[0]; expect(result).toContain(`Error: Command '${firstToken}' is not allowed`); expect(mockExecFileSync).not.toHaveBeenCalled(); } }); it('should handle path arguments in commands', async () => { mockExecFileSync.mockReturnValue('output'); const pathCommands = [ 'show ../../../etc/passwd', 'diff ..\\..\\windows\\system32', 'log %2e%2e%2fetc%2fpasswd', 'blame ..%2f..%2f..%2fsensitive', ]; // The implementation doesn't block path traversal in arguments // Git itself would handle these paths for (const cmd of pathCommands) { jest.clearAllMocks(); const result = await executor.execute(cmd, '/test/repo', false); expect(result).toBe('output'); expect(mockExecFileSync).toHaveBeenCalled(); } }); it('should sanitize control characters', async () => { mockExecFileSync.mockReturnValue('output'); const dirtyCommand = 'log\x00\x01\x02\x1F --oneline'; const result = await executor.execute(dirtyCommand, '/test/repo', false); // Should execute with cleaned command expect(mockExecFileSync).toHaveBeenCalledWith( 'git', expect.arrayContaining(['log', '--oneline']), expect.any(Object) ); }); it('should enforce command length limit', async () => { const longCommand = 'log ' + 'a'.repeat(2000); const result = await executor.execute(longCommand, '/test/repo', false); expect(result).toContain('Error: Command too long'); expect(result).toContain('max 1000 characters'); expect(mockExecFileSync).not.toHaveBeenCalled(); }); }); describe('Git Execution', () => { it('should set correct environment variables', async () => { mockExecFileSync.mockReturnValue('output'); await executor.execute('log', '/test/repo', false); expect(mockExecFileSync).toHaveBeenCalledWith( 'git', [ `--git-dir=${path.join('/test/repo', '.shadowgit.git')}`, '--work-tree=/test/repo', 'log' ], expect.objectContaining({ cwd: '/test/repo', encoding: 'utf-8', timeout: 10000, maxBuffer: 10 * 1024 * 1024, env: expect.objectContaining({ GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', GIT_PAGER: 'cat', PAGER: 'cat' }) }) ); }); it('should pass custom environment variables', async () => { mockExecFileSync.mockReturnValue('output'); const customEnv = { GIT_AUTHOR_NAME: 'Test User', GIT_AUTHOR_EMAIL: 'test@example.com', }; await executor.execute('commit -m "test"', '/test/repo', true, customEnv); expect(mockExecFileSync).toHaveBeenCalledWith( 'git', [ `--git-dir=${path.join('/test/repo', '.shadowgit.git')}`, '--work-tree=/test/repo', 'commit', '-m', 'test' ], expect.objectContaining({ env: expect.objectContaining({ GIT_AUTHOR_NAME: 'Test User', GIT_AUTHOR_EMAIL: 'test@example.com', }), }) ); }); it('should handle successful command execution', async () => { const expectedOutput = 'commit abc1234\nAuthor: Test'; mockExecFileSync.mockReturnValue(expectedOutput); const result = await executor.execute('log -1', '/test/repo', false); expect(result).toBe(expectedOutput); }); it('should handle empty output', async () => { mockExecFileSync.mockReturnValue(''); const result = await executor.execute('status', '/test/repo', false); expect(result).toBe('(empty output)'); }); it('should handle multi-line output', async () => { const multiLine = 'line1\nline2\nline3\n'; mockExecFileSync.mockReturnValue(multiLine); const result = await executor.execute('log', '/test/repo', false); expect(result).toBe(multiLine); }); }); describe('Error Handling', () => { it('should handle git not installed (ENOENT)', async () => { const error: any = new Error('Command not found'); error.code = 'ENOENT'; mockExecFileSync.mockImplementation(() => { throw error; }); const result = await executor.execute('log', '/test/repo', false); // ENOENT won't have stderr/stdout, falls to generic error expect(result).toBe('Error: Error: Command not found'); }); it('should handle timeout (ETIMEDOUT)', async () => { const error: any = new Error('Command timeout'); error.code = 'ETIMEDOUT'; mockExecFileSync.mockImplementation(() => { throw error; }); const result = await executor.execute('log', '/test/repo', false); expect(result).toContain('Error: Command timed out after'); expect(result).toContain('ms'); }); it('should handle buffer overflow (ENOBUFS)', async () => { const error: any = new Error('Buffer overflow'); error.code = 'ENOBUFS'; mockExecFileSync.mockImplementation(() => { throw error; }); const result = await executor.execute('log', '/test/repo', false); // ENOBUFS won't have stderr/stdout, falls to generic error expect(result).toBe('Error: Error: Buffer overflow'); }); it('should handle git errors (exit code 128)', async () => { const error: any = new Error('Git error'); error.status = 128; error.code = 'GITERROR'; error.stderr = Buffer.from('fatal: bad revision'); mockExecFileSync.mockImplementation(() => { throw error; }); const result = await executor.execute('log bad-ref', '/test/repo', false); expect(result).toContain('Error executing git command'); expect(result).toContain('fatal: bad revision'); }); it('should handle git errors with status but no stderr', async () => { const error: any = new Error('Git failed'); error.status = 1; // Need 'code' property for it to go through the detailed error path error.code = 'GITERROR'; mockExecFileSync.mockImplementation(() => { throw error; }); const result = await executor.execute('log', '/test/repo', false); expect(result).toContain('Error executing git command'); expect(result).toContain('Git failed'); }); it('should handle generic errors', async () => { mockExecFileSync.mockImplementation(() => { throw new Error('Unexpected error'); }); const result = await executor.execute('log', '/test/repo', false); // Generic errors without code property go through the fallback expect(result).toBe('Error: Error: Unexpected error'); }); it('should handle non-Error objects thrown', async () => { mockExecFileSync.mockImplementation(() => { throw 'String error'; }); const result = await executor.execute('log', '/test/repo', false); expect(result).toContain('Error: String error'); }); }); describe('Special Cases', () => { it('should handle Windows-style paths', async () => { mockExecFileSync.mockReturnValue('output'); const windowsPath = 'C:\\Users\\Test\\Project'; await executor.execute('log', windowsPath, false); expect(mockExecFileSync).toHaveBeenCalledWith( 'git', [ `--git-dir=${path.join(windowsPath, '.shadowgit.git')}`, `--work-tree=${windowsPath}`, 'log' ], expect.objectContaining({ cwd: windowsPath, }) ); }); it('should handle paths with spaces', async () => { mockExecFileSync.mockReturnValue('output'); const pathWithSpaces = '/path/with spaces/project'; await executor.execute('log', pathWithSpaces, false); expect(mockExecFileSync).toHaveBeenCalledWith( 'git', [ `--git-dir=${path.join(pathWithSpaces, '.shadowgit.git')}`, `--work-tree=${pathWithSpaces}`, 'log' ], expect.objectContaining({ cwd: pathWithSpaces, }) ); }); it('should handle Unicode in output', async () => { const unicodeOutput = 'commit with emoji 🎉 and 中文'; mockExecFileSync.mockReturnValue(unicodeOutput); const result = await executor.execute('log', '/test/repo', false); expect(result).toBe(unicodeOutput); }); it('should handle binary output gracefully', async () => { // When encoding is specified, execFileSync returns a string even for binary data // It will be garbled but still a string const garbledString = '\uFFFD\uFFFD\u0000\u0001'; mockExecFileSync.mockReturnValue(garbledString); const result = await executor.execute('cat-file -p HEAD:binary', '/test/repo', false); // Should return a string expect(typeof result).toBe('string'); expect(result).toBe(garbledString); }); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/blade47/shadowgit-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server