gemini-executor 0.1.0

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,257 @@
1
+ /**
2
+ * Security Tests for Gemini Executor
3
+ *
4
+ * Tests input sanitization, path validation, and sensitive file detection
5
+ * to ensure the agent is protected against common security vulnerabilities.
6
+ */
7
+
8
+ import * as path from 'path';
9
+ import * as fs from 'fs';
10
+
11
+ // Note: We'll need to refactor index.ts to export these functions
12
+ // For now, we'll create wrapper functions for testing
13
+
14
+ describe('Security Tests', () => {
15
+ describe('Input Sanitization', () => {
16
+ /**
17
+ * sanitizeInput() should remove dangerous shell characters
18
+ */
19
+ test('should remove dangerous shell characters', () => {
20
+ const dangerous = 'hello; rm -rf /';
21
+ const sanitized = sanitizeInput(dangerous);
22
+
23
+ expect(sanitized).not.toContain(';');
24
+ expect(sanitized).not.toContain('|');
25
+ expect(sanitized).not.toContain('&');
26
+ expect(sanitized).not.toContain('`');
27
+ });
28
+
29
+ test('should escape backslashes', () => {
30
+ const input = 'path\\to\\file';
31
+ const sanitized = sanitizeInput(input);
32
+
33
+ expect(sanitized).toContain('\\\\');
34
+ });
35
+
36
+ test('should escape double quotes', () => {
37
+ const input = 'say "hello world"';
38
+ const sanitized = sanitizeInput(input);
39
+
40
+ expect(sanitized).toContain('\\"');
41
+ });
42
+
43
+ test('should handle empty input', () => {
44
+ const input = '';
45
+ const sanitized = sanitizeInput(input);
46
+
47
+ expect(sanitized).toBe('');
48
+ });
49
+
50
+ test('should preserve safe characters', () => {
51
+ const input = 'Hello World 123!';
52
+ const sanitized = sanitizeInput(input);
53
+
54
+ expect(sanitized).toContain('Hello');
55
+ expect(sanitized).toContain('World');
56
+ expect(sanitized).toContain('123');
57
+ expect(sanitized).toContain('!');
58
+ });
59
+
60
+ test('should remove command injection attempts', () => {
61
+ const attacks = [
62
+ 'hello $(whoami)',
63
+ 'hello `whoami`',
64
+ 'hello && rm -rf /',
65
+ 'hello || cat /etc/passwd',
66
+ 'hello | grep secret'
67
+ ];
68
+
69
+ attacks.forEach(attack => {
70
+ const sanitized = sanitizeInput(attack);
71
+ expect(sanitized).not.toMatch(/\$\(/);
72
+ expect(sanitized).not.toContain('`');
73
+ expect(sanitized).not.toContain('&&');
74
+ expect(sanitized).not.toContain('||');
75
+ expect(sanitized).not.toContain('|');
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('Path Validation', () => {
81
+ const testDir = '/tmp/gemini-test';
82
+
83
+ beforeAll(() => {
84
+ // Create test directory
85
+ if (!fs.existsSync(testDir)) {
86
+ fs.mkdirSync(testDir, { recursive: true });
87
+ }
88
+ // Create a test file
89
+ fs.writeFileSync(path.join(testDir, 'test.txt'), 'test content');
90
+ });
91
+
92
+ afterAll(() => {
93
+ // Cleanup
94
+ if (fs.existsSync(testDir)) {
95
+ fs.rmSync(testDir, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test('should reject directory traversal attempts', () => {
100
+ const maliciousPath = '../../../etc/passwd';
101
+
102
+ expect(() => {
103
+ validateFilePath(maliciousPath, testDir);
104
+ }).toThrow('directory traversal detected');
105
+ });
106
+
107
+ test('should reject paths with .. in them', () => {
108
+ const paths = [
109
+ '../../secret',
110
+ '../config',
111
+ 'folder/../secret',
112
+ './../../etc/passwd'
113
+ ];
114
+
115
+ paths.forEach(p => {
116
+ expect(() => {
117
+ validateFilePath(p, testDir);
118
+ }).toThrow();
119
+ });
120
+ });
121
+
122
+ test('should accept valid absolute paths', () => {
123
+ const validPath = path.join(testDir, 'test.txt');
124
+ const result = validateFilePath(validPath, testDir);
125
+
126
+ expect(result).toBe(validPath);
127
+ });
128
+
129
+ test('should throw error for non-existent files', () => {
130
+ const nonExistent = path.join(testDir, 'does-not-exist.txt');
131
+
132
+ expect(() => {
133
+ validateFilePath(nonExistent, testDir);
134
+ }).toThrow('File not found');
135
+ });
136
+
137
+ test('should handle relative paths correctly', () => {
138
+ const relativePath = 'test.txt';
139
+ const result = validateFilePath(relativePath, testDir);
140
+
141
+ expect(result).toBe(path.join(testDir, 'test.txt'));
142
+ });
143
+ });
144
+
145
+ describe('Sensitive File Detection', () => {
146
+ test('should detect .env files', () => {
147
+ expect(isSensitiveFile('.env')).toBe(true);
148
+ expect(isSensitiveFile('.env.local')).toBe(true);
149
+ expect(isSensitiveFile('.env.production')).toBe(true);
150
+ expect(isSensitiveFile('/path/to/.env')).toBe(true);
151
+ });
152
+
153
+ test('should detect credential files', () => {
154
+ expect(isSensitiveFile('credentials.json')).toBe(true);
155
+ expect(isSensitiveFile('secrets.yaml')).toBe(true);
156
+ expect(isSensitiveFile('/path/to/credentials.json')).toBe(true);
157
+ });
158
+
159
+ test('should detect key files', () => {
160
+ expect(isSensitiveFile('private.key')).toBe(true);
161
+ expect(isSensitiveFile('certificate.pem')).toBe(true);
162
+ expect(isSensitiveFile('id_rsa')).toBe(true);
163
+ });
164
+
165
+ test('should detect SSH directory', () => {
166
+ expect(isSensitiveFile('/home/user/.ssh/id_rsa')).toBe(true);
167
+ expect(isSensitiveFile('.ssh/known_hosts')).toBe(true);
168
+ });
169
+
170
+ test('should not flag safe files', () => {
171
+ const safeFiles = [
172
+ 'README.md',
173
+ 'package.json',
174
+ 'index.ts',
175
+ 'test.txt',
176
+ 'config.js'
177
+ ];
178
+
179
+ safeFiles.forEach(file => {
180
+ expect(isSensitiveFile(file)).toBe(false);
181
+ });
182
+ });
183
+
184
+ test('should be case-insensitive for extensions', () => {
185
+ expect(isSensitiveFile('file.KEY')).toBe(true);
186
+ expect(isSensitiveFile('file.PEM')).toBe(true);
187
+ });
188
+ });
189
+
190
+ describe('Command Injection Prevention', () => {
191
+ test('should prevent shell metacharacter injection', () => {
192
+ const attempts = [
193
+ 'test; ls -la',
194
+ 'test && cat /etc/passwd',
195
+ 'test | grep password',
196
+ 'test > /dev/null',
197
+ 'test < input.txt',
198
+ 'test $(malicious)',
199
+ 'test `whoami`'
200
+ ];
201
+
202
+ attempts.forEach(attempt => {
203
+ const sanitized = sanitizeInput(attempt);
204
+ // Verify dangerous characters are removed
205
+ expect(sanitized).not.toMatch(/[;&|<>`$()]/);
206
+ });
207
+ });
208
+
209
+ test('should handle nested command injection attempts', () => {
210
+ const nested = 'test $(echo $(whoami))';
211
+ const sanitized = sanitizeInput(nested);
212
+
213
+ expect(sanitized).not.toContain('$');
214
+ expect(sanitized).not.toContain('(');
215
+ expect(sanitized).not.toContain(')');
216
+ });
217
+ });
218
+ });
219
+
220
+ // Helper functions (these should be exported from index.ts)
221
+ function sanitizeInput(input: string): string {
222
+ return input
223
+ .replace(/[;&|`$()<>]/g, '')
224
+ .replace(/\\/g, '\\\\')
225
+ .replace(/"/g, '\\"');
226
+ }
227
+
228
+ function validateFilePath(filePath: string, workingDir?: string): string {
229
+ const baseDir = path.resolve(workingDir || process.cwd());
230
+ const absolutePath = path.resolve(baseDir, filePath);
231
+
232
+ // Check if the resolved path is within the base directory
233
+ if (!absolutePath.startsWith(baseDir + path.sep) && absolutePath !== baseDir) {
234
+ throw new Error(`Invalid file path: directory traversal detected in ${filePath}`);
235
+ }
236
+
237
+ if (!fs.existsSync(absolutePath)) {
238
+ throw new Error(`File not found: ${absolutePath}`);
239
+ }
240
+
241
+ return absolutePath;
242
+ }
243
+
244
+ function isSensitiveFile(filePath: string): boolean {
245
+ const patterns = [
246
+ /\.env$/i,
247
+ /\.env\./i,
248
+ /credentials\.json$/i,
249
+ /secrets\.yaml$/i,
250
+ /\.key$/i,
251
+ /\.pem$/i,
252
+ /id_rsa$/i,
253
+ /\.ssh\//i
254
+ ];
255
+
256
+ return patterns.some(pattern => pattern.test(filePath));
257
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Validation Tests for Gemini Executor
3
+ *
4
+ * Tests input validation, option validation, and error handling logic.
5
+ */
6
+
7
+ describe('Validation Tests', () => {
8
+ describe('Query Validation', () => {
9
+ test('should reject empty query', () => {
10
+ const options = { query: '' };
11
+
12
+ expect(() => {
13
+ validateQuery(options.query);
14
+ }).toThrow('Query cannot be empty');
15
+ });
16
+
17
+ test('should reject whitespace-only query', () => {
18
+ const options = { query: ' \n\t ' };
19
+
20
+ expect(() => {
21
+ validateQuery(options.query);
22
+ }).toThrow('Query cannot be empty');
23
+ });
24
+
25
+ test('should accept valid query', () => {
26
+ const options = { query: 'Analyze this project' };
27
+
28
+ expect(() => {
29
+ validateQuery(options.query);
30
+ }).not.toThrow();
31
+ });
32
+
33
+ test('should accept query with special characters', () => {
34
+ const queries = [
35
+ 'What is $variable?',
36
+ 'Explain "dependency injection"',
37
+ 'How to use Array<T>?',
38
+ 'Calculate 1 + 1 = ?'
39
+ ];
40
+
41
+ queries.forEach(query => {
42
+ expect(() => {
43
+ validateQuery(query);
44
+ }).not.toThrow();
45
+ });
46
+ });
47
+
48
+ test('should handle very long queries', () => {
49
+ const longQuery = 'a'.repeat(10000);
50
+
51
+ expect(() => {
52
+ validateQuery(longQuery);
53
+ }).not.toThrow();
54
+ });
55
+ });
56
+
57
+ describe('Output Format Validation', () => {
58
+ test('should accept valid output formats', () => {
59
+ const validFormats = ['text', 'json', 'stream-json'];
60
+
61
+ validFormats.forEach(format => {
62
+ expect(() => {
63
+ validateOutputFormat(format as any);
64
+ }).not.toThrow();
65
+ });
66
+ });
67
+
68
+ test('should reject invalid output formats', () => {
69
+ const invalidFormats = ['xml', 'csv', 'yaml', 'html'];
70
+
71
+ invalidFormats.forEach(format => {
72
+ expect(() => {
73
+ validateOutputFormat(format as any);
74
+ }).toThrow('Invalid output format');
75
+ });
76
+ });
77
+
78
+ test('should handle case sensitivity', () => {
79
+ expect(() => {
80
+ validateOutputFormat('TEXT' as any);
81
+ }).toThrow();
82
+
83
+ expect(() => {
84
+ validateOutputFormat('JSON' as any);
85
+ }).toThrow();
86
+ });
87
+
88
+ test('should default to text format', () => {
89
+ const result = validateOutputFormat(undefined);
90
+ expect(result).toBe('text');
91
+ });
92
+ });
93
+
94
+ describe('Model Validation', () => {
95
+ test('should accept valid model names', () => {
96
+ const validModels = [
97
+ 'gemini-2.0-flash',
98
+ 'gemini-2.0-flash-thinking-exp',
99
+ 'gemini-pro'
100
+ ];
101
+
102
+ validModels.forEach(model => {
103
+ expect(() => {
104
+ validateModel(model);
105
+ }).not.toThrow();
106
+ });
107
+ });
108
+
109
+ test('should handle undefined model (use default)', () => {
110
+ const result = validateModel(undefined);
111
+ expect(result).toBe('gemini-2.0-flash');
112
+ });
113
+
114
+ test('should accept custom model names', () => {
115
+ expect(() => {
116
+ validateModel('custom-model-v1');
117
+ }).not.toThrow();
118
+ });
119
+ });
120
+
121
+ describe('File Array Validation', () => {
122
+ test('should accept empty file array', () => {
123
+ expect(() => {
124
+ validateFiles([]);
125
+ }).not.toThrow();
126
+ });
127
+
128
+ test('should accept undefined files', () => {
129
+ expect(() => {
130
+ validateFiles(undefined);
131
+ }).not.toThrow();
132
+ });
133
+
134
+ test('should accept array of file paths', () => {
135
+ const files = ['/path/to/file1.txt', '/path/to/file2.txt'];
136
+
137
+ expect(() => {
138
+ validateFiles(files);
139
+ }).not.toThrow();
140
+ });
141
+
142
+ test('should reject non-string elements', () => {
143
+ const files = ['/path/file.txt', 123, '/another/file.txt'] as any;
144
+
145
+ expect(() => {
146
+ validateFiles(files);
147
+ }).toThrow('All file paths must be strings');
148
+ });
149
+
150
+ test('should reject empty string paths', () => {
151
+ const files = ['/valid/path.txt', '', '/another/path.txt'];
152
+
153
+ expect(() => {
154
+ validateFiles(files);
155
+ }).toThrow('File paths cannot be empty');
156
+ });
157
+ });
158
+
159
+ describe('Configuration Validation', () => {
160
+ test('should accept valid configuration', () => {
161
+ const config = {
162
+ cliPath: '/usr/bin/gemini',
163
+ defaultModel: 'gemini-2.0-flash',
164
+ maxRetries: 3,
165
+ timeout: 120000,
166
+ yolo: true
167
+ };
168
+
169
+ expect(() => {
170
+ validateConfig(config);
171
+ }).not.toThrow();
172
+ });
173
+
174
+ test('should reject negative maxRetries', () => {
175
+ const config = { maxRetries: -1 };
176
+
177
+ expect(() => {
178
+ validateConfig(config);
179
+ }).toThrow('maxRetries must be non-negative');
180
+ });
181
+
182
+ test('should reject zero or negative timeout', () => {
183
+ expect(() => {
184
+ validateConfig({ timeout: 0 });
185
+ }).toThrow('timeout must be positive');
186
+
187
+ expect(() => {
188
+ validateConfig({ timeout: -1000 });
189
+ }).toThrow('timeout must be positive');
190
+ });
191
+
192
+ test('should accept partial configuration', () => {
193
+ const config = { maxRetries: 5 };
194
+
195
+ expect(() => {
196
+ validateConfig(config);
197
+ }).not.toThrow();
198
+ });
199
+
200
+ test('should reject timeout exceeding maximum', () => {
201
+ const config = { timeout: 700000 }; // > 600000 (10 min)
202
+
203
+ expect(() => {
204
+ validateConfig(config);
205
+ }).toThrow('timeout exceeds maximum');
206
+ });
207
+
208
+ test('should accept timeout at maximum', () => {
209
+ const config = { timeout: 600000 }; // exactly 10 min
210
+
211
+ expect(() => {
212
+ validateConfig(config);
213
+ }).not.toThrow();
214
+ });
215
+ });
216
+
217
+ describe('Working Directory Validation', () => {
218
+ test('should accept valid directory', () => {
219
+ const dir = process.cwd();
220
+
221
+ expect(() => {
222
+ validateWorkingDir(dir);
223
+ }).not.toThrow();
224
+ });
225
+
226
+ test('should reject non-existent directory', () => {
227
+ const dir = '/this/directory/does/not/exist';
228
+
229
+ expect(() => {
230
+ validateWorkingDir(dir);
231
+ }).toThrow('Working directory does not exist');
232
+ });
233
+
234
+ test('should accept undefined (use current directory)', () => {
235
+ expect(() => {
236
+ validateWorkingDir(undefined);
237
+ }).not.toThrow();
238
+ });
239
+
240
+ test('should accept home directory shorthand', () => {
241
+ const dir = '~/projects';
242
+ const resolved = validateWorkingDir(dir);
243
+
244
+ expect(resolved).not.toContain('~');
245
+ expect(resolved).toContain('projects');
246
+ });
247
+ });
248
+
249
+ describe('Interactive Mode Validation', () => {
250
+ test('should warn if interactive mode with yolo', () => {
251
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
252
+
253
+ validateInteractiveMode(true, true);
254
+
255
+ expect(warnSpy).toHaveBeenCalledWith(
256
+ expect.stringContaining('Interactive mode with yolo')
257
+ );
258
+
259
+ warnSpy.mockRestore();
260
+ });
261
+
262
+ test('should not warn for interactive without yolo', () => {
263
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
264
+
265
+ validateInteractiveMode(true, false);
266
+
267
+ expect(warnSpy).not.toHaveBeenCalled();
268
+
269
+ warnSpy.mockRestore();
270
+ });
271
+
272
+ test('should not warn for non-interactive with yolo', () => {
273
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
274
+
275
+ validateInteractiveMode(false, true);
276
+
277
+ expect(warnSpy).not.toHaveBeenCalled();
278
+
279
+ warnSpy.mockRestore();
280
+ });
281
+ });
282
+ });
283
+
284
+ // Helper validation functions (these should be in index.ts or a validation module)
285
+ function validateQuery(query: string): void {
286
+ if (!query || query.trim().length === 0) {
287
+ throw new Error('Query cannot be empty');
288
+ }
289
+ }
290
+
291
+ function validateOutputFormat(format?: string): string {
292
+ const validFormats = ['text', 'json', 'stream-json'];
293
+
294
+ if (!format) {
295
+ return 'text';
296
+ }
297
+
298
+ if (!validFormats.includes(format)) {
299
+ throw new Error(`Invalid output format: ${format}`);
300
+ }
301
+
302
+ return format;
303
+ }
304
+
305
+ function validateModel(model?: string): string {
306
+ if (!model) {
307
+ return 'gemini-2.0-flash';
308
+ }
309
+
310
+ return model;
311
+ }
312
+
313
+ function validateFiles(files?: string[]): void {
314
+ if (!files) {
315
+ return;
316
+ }
317
+
318
+ if (!Array.isArray(files)) {
319
+ throw new Error('Files must be an array');
320
+ }
321
+
322
+ files.forEach((file, index) => {
323
+ if (typeof file !== 'string') {
324
+ throw new Error(`All file paths must be strings (invalid at index ${index})`);
325
+ }
326
+
327
+ if (file.trim().length === 0) {
328
+ throw new Error(`File paths cannot be empty (at index ${index})`);
329
+ }
330
+ });
331
+ }
332
+
333
+ function validateConfig(config: any): void {
334
+ if (config.maxRetries !== undefined) {
335
+ if (typeof config.maxRetries !== 'number' || config.maxRetries < 0) {
336
+ throw new Error('maxRetries must be non-negative');
337
+ }
338
+ }
339
+
340
+ if (config.timeout !== undefined) {
341
+ if (typeof config.timeout !== 'number' || config.timeout <= 0) {
342
+ throw new Error('timeout must be positive');
343
+ }
344
+
345
+ if (config.timeout > 600000) {
346
+ throw new Error('timeout exceeds maximum (600000ms / 10 minutes)');
347
+ }
348
+ }
349
+ }
350
+
351
+ function validateWorkingDir(dir?: string): string {
352
+ if (!dir) {
353
+ return process.cwd();
354
+ }
355
+
356
+ // Expand home directory
357
+ const expanded = dir.replace(/^~/, require('os').homedir());
358
+
359
+ if (!require('fs').existsSync(expanded)) {
360
+ throw new Error(`Working directory does not exist: ${expanded}`);
361
+ }
362
+
363
+ return expanded;
364
+ }
365
+
366
+ function validateInteractiveMode(interactive: boolean, yolo: boolean): void {
367
+ if (interactive && yolo) {
368
+ console.warn(
369
+ 'Warning: Interactive mode with yolo flag may not work as expected. ' +
370
+ 'Consider using interactive without yolo for user prompts.'
371
+ );
372
+ }
373
+ }