@probelabs/probe 0.6.0-rc168 → 0.6.0-rc170

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.
@@ -13,11 +13,11 @@ import { randomUUID } from 'crypto';
13
13
  import { EventEmitter } from 'events';
14
14
  import { existsSync } from 'fs';
15
15
  import { readFile, stat } from 'fs/promises';
16
- import { resolve, isAbsolute, dirname } from 'path';
16
+ import { resolve, isAbsolute, dirname, basename, normalize, sep } from 'path';
17
17
  import { TokenCounter } from './tokenCounter.js';
18
18
  import { InMemoryStorageAdapter } from './storage/InMemoryStorageAdapter.js';
19
19
  import { HookManager, HOOK_TYPES } from './hooks/HookManager.js';
20
- import { SUPPORTED_IMAGE_EXTENSIONS, IMAGE_MIME_TYPES } from './imageConfig.js';
20
+ import { SUPPORTED_IMAGE_EXTENSIONS, IMAGE_MIME_TYPES, isFormatSupportedByProvider } from './imageConfig.js';
21
21
  import {
22
22
  createTools,
23
23
  searchToolDefinition,
@@ -420,14 +420,20 @@ export class ProbeAgent {
420
420
  * Initialize tools with configuration
421
421
  */
422
422
  initializeTools() {
423
+ const isToolAllowed = (toolName) => this.allowedTools.isEnabled(toolName);
424
+
423
425
  const configOptions = {
424
426
  sessionId: this.sessionId,
425
427
  debug: this.debug,
426
428
  defaultPath: this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd(),
427
429
  allowedFolders: this.allowedFolders,
428
430
  outline: this.outline,
431
+ allowEdit: this.allowEdit,
432
+ enableDelegate: this.enableDelegate,
429
433
  enableBash: this.enableBash,
430
- bashConfig: this.bashConfig
434
+ bashConfig: this.bashConfig,
435
+ allowedTools: this.allowedTools,
436
+ isToolAllowed
431
437
  };
432
438
 
433
439
  // Create base tools
@@ -436,21 +442,54 @@ export class ProbeAgent {
436
442
  // Create wrapped tools with event emission
437
443
  const wrappedTools = createWrappedTools(baseTools);
438
444
 
439
- // Store tool instances for execution
440
- this.toolImplementations = {
441
- search: wrappedTools.searchToolInstance,
442
- query: wrappedTools.queryToolInstance,
443
- extract: wrappedTools.extractToolInstance,
444
- delegate: wrappedTools.delegateToolInstance,
445
- listFiles: listFilesToolInstance,
446
- searchFiles: searchFilesToolInstance,
447
- readImage: {
445
+ // Store tool instances for execution (respect allowedTools + feature flags)
446
+ this.toolImplementations = {};
447
+
448
+ if (wrappedTools.searchToolInstance && isToolAllowed('search')) {
449
+ this.toolImplementations.search = wrappedTools.searchToolInstance;
450
+ }
451
+ if (wrappedTools.queryToolInstance && isToolAllowed('query')) {
452
+ this.toolImplementations.query = wrappedTools.queryToolInstance;
453
+ }
454
+ if (wrappedTools.extractToolInstance && isToolAllowed('extract')) {
455
+ this.toolImplementations.extract = wrappedTools.extractToolInstance;
456
+ }
457
+ if (this.enableDelegate && wrappedTools.delegateToolInstance && isToolAllowed('delegate')) {
458
+ this.toolImplementations.delegate = wrappedTools.delegateToolInstance;
459
+ }
460
+
461
+ // File browsing tools
462
+ if (isToolAllowed('listFiles')) {
463
+ this.toolImplementations.listFiles = listFilesToolInstance;
464
+ }
465
+ if (isToolAllowed('searchFiles')) {
466
+ this.toolImplementations.searchFiles = searchFilesToolInstance;
467
+ }
468
+
469
+ // Image loading tool
470
+ if (isToolAllowed('readImage')) {
471
+ this.toolImplementations.readImage = {
448
472
  execute: async (params) => {
449
473
  const imagePath = params.path;
450
474
  if (!imagePath) {
451
475
  throw new Error('Image path is required');
452
476
  }
453
477
 
478
+ // Validate extension before attempting to load
479
+ // Use basename to prevent path traversal attacks (e.g., 'malicious.jpg/../../../etc/passwd')
480
+ const filename = basename(imagePath);
481
+ const extension = filename.toLowerCase().split('.').pop();
482
+
483
+ // Always validate extension is in allowed list (defense-in-depth)
484
+ if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
485
+ throw new Error(`Invalid or unsupported image extension: ${extension}. Supported formats: ${SUPPORTED_IMAGE_EXTENSIONS.join(', ')}`);
486
+ }
487
+
488
+ // Check provider-specific format restrictions (e.g., SVG not supported by Google Gemini)
489
+ if (this.apiType && !isFormatSupportedByProvider(extension, this.apiType)) {
490
+ throw new Error(`Image format '${extension}' is not supported by the current AI provider (${this.apiType}). Try using a different image format like PNG or JPEG.`);
491
+ }
492
+
454
493
  // Load the image using the existing loadImageIfValid method
455
494
  const loaded = await this.loadImageIfValid(imagePath);
456
495
 
@@ -460,20 +499,20 @@ export class ProbeAgent {
460
499
 
461
500
  return `Image loaded successfully: ${imagePath}. The image is now available for analysis in the conversation.`;
462
501
  }
463
- }
464
- };
502
+ };
503
+ }
465
504
 
466
- // Add bash tool if enabled
467
- if (this.enableBash && wrappedTools.bashToolInstance) {
505
+ // Add bash tool if enabled and allowed
506
+ if (this.enableBash && wrappedTools.bashToolInstance && isToolAllowed('bash')) {
468
507
  this.toolImplementations.bash = wrappedTools.bashToolInstance;
469
508
  }
470
509
 
471
- // Add edit and create tools if enabled
510
+ // Add edit and create tools if enabled and allowed
472
511
  if (this.allowEdit) {
473
- if (wrappedTools.editToolInstance) {
512
+ if (wrappedTools.editToolInstance && isToolAllowed('edit')) {
474
513
  this.toolImplementations.edit = wrappedTools.editToolInstance;
475
514
  }
476
- if (wrappedTools.createToolInstance) {
515
+ if (wrappedTools.createToolInstance && isToolAllowed('create')) {
477
516
  this.toolImplementations.create = wrappedTools.createToolInstance;
478
517
  }
479
518
  }
@@ -1238,20 +1277,28 @@ export class ProbeAgent {
1238
1277
  }
1239
1278
 
1240
1279
  // Security validation: check if path is within any allowed directory
1280
+ // Use normalize() after resolve() to handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1241
1281
  const allowedDirs = this.allowedFolders && this.allowedFolders.length > 0 ? this.allowedFolders : [process.cwd()];
1242
-
1282
+
1243
1283
  let absolutePath;
1244
1284
  let isPathAllowed = false;
1245
-
1285
+
1246
1286
  // If absolute path, check if it's within any allowed directory
1247
1287
  if (isAbsolute(imagePath)) {
1248
- absolutePath = imagePath;
1249
- isPathAllowed = allowedDirs.some(dir => absolutePath.startsWith(resolve(dir)));
1288
+ // Normalize to resolve any '..' sequences
1289
+ absolutePath = normalize(resolve(imagePath));
1290
+ isPathAllowed = allowedDirs.some(dir => {
1291
+ const normalizedDir = normalize(resolve(dir));
1292
+ // Ensure the path is within the allowed directory (add separator to prevent prefix attacks)
1293
+ return absolutePath === normalizedDir || absolutePath.startsWith(normalizedDir + sep);
1294
+ });
1250
1295
  } else {
1251
1296
  // For relative paths, try resolving against each allowed directory
1252
1297
  for (const dir of allowedDirs) {
1253
- const resolvedPath = resolve(dir, imagePath);
1254
- if (resolvedPath.startsWith(resolve(dir))) {
1298
+ const normalizedDir = normalize(resolve(dir));
1299
+ const resolvedPath = normalize(resolve(dir, imagePath));
1300
+ // Ensure the resolved path is within the allowed directory
1301
+ if (resolvedPath === normalizedDir || resolvedPath.startsWith(normalizedDir + sep)) {
1255
1302
  absolutePath = resolvedPath;
1256
1303
  isPathAllowed = true;
1257
1304
  break;
@@ -1295,6 +1342,10 @@ export class ProbeAgent {
1295
1342
  return false;
1296
1343
  }
1297
1344
 
1345
+ // Note: Provider-specific format validation (e.g., SVG not supported by Google Gemini)
1346
+ // is handled by the readImage tool which provides explicit error messages.
1347
+ // loadImageIfValid is a lower-level method that only checks general format support.
1348
+
1298
1349
  // Determine MIME type (from shared config)
1299
1350
  const mimeType = IMAGE_MIME_TYPES[extension];
1300
1351
 
@@ -2480,7 +2531,7 @@ When troubleshooting:
2480
2531
  maxIterations,
2481
2532
  parentSessionId: this.sessionId, // Pass parent session ID for tracking
2482
2533
  path: this.searchPath, // Inherit search path
2483
- provider: this.provider, // Inherit AI provider
2534
+ provider: this.apiType, // Inherit AI provider (string identifier)
2484
2535
  model: this.model, // Inherit model
2485
2536
  debug: this.debug,
2486
2537
  tracer: this.tracer
@@ -2489,7 +2540,7 @@ When troubleshooting:
2489
2540
  if (this.debug) {
2490
2541
  console.log(`[DEBUG] Executing delegate tool at iteration ${currentIteration}/${maxIterations}`);
2491
2542
  console.log(`[DEBUG] Parent session: ${this.sessionId}`);
2492
- console.log(`[DEBUG] Inherited config: path=${this.searchPath}, provider=${this.provider}, model=${this.model}`);
2543
+ console.log(`[DEBUG] Inherited config: path=${this.searchPath}, provider=${this.apiType}, model=${this.model}`);
2493
2544
  console.log(`[DEBUG] Delegate task: ${toolParams.task?.substring(0, 100)}...`);
2494
2545
  }
2495
2546
 
@@ -2573,10 +2624,10 @@ When troubleshooting:
2573
2624
  content: toolResultMessage
2574
2625
  });
2575
2626
 
2576
- // Process tool result for image references
2577
- if (toolResultContent) {
2578
- await this.processImageReferences(toolResultContent);
2579
- }
2627
+ // NOTE: Automatic image processing removed (GitHub issue #305)
2628
+ // Images are now only loaded when the AI explicitly calls the readImage tool
2629
+ // This prevents: 1) implicit behavior that users don't expect
2630
+ // 2) crashes with unsupported MIME types (e.g., SVG on Gemini)
2580
2631
 
2581
2632
  if (this.debug) {
2582
2633
  console.log(`[DEBUG] Tool ${toolName} executed successfully. Result length: ${typeof toolResult === 'string' ? toolResult.length : JSON.stringify(toolResult).length}`);
@@ -21,6 +21,12 @@ export const IMAGE_MIME_TYPES = {
21
21
  'svg': 'image/svg+xml'
22
22
  };
23
23
 
24
+ // Provider-specific unsupported image formats
25
+ // These providers do not support certain MIME types and will crash if they receive them
26
+ export const PROVIDER_UNSUPPORTED_FORMATS = {
27
+ 'google': ['svg'], // Google Gemini doesn't support image/svg+xml
28
+ };
29
+
24
30
  /**
25
31
  * Generate a regex pattern string for matching image file extensions
26
32
  * @param {string[]} extensions - Array of extensions (without dots)
@@ -38,3 +44,54 @@ export function getExtensionPattern(extensions = SUPPORTED_IMAGE_EXTENSIONS) {
38
44
  export function getMimeType(extension) {
39
45
  return IMAGE_MIME_TYPES[extension.toLowerCase()];
40
46
  }
47
+
48
+ /**
49
+ * Check if an image extension is supported by a specific provider
50
+ * @param {string} extension - File extension (without dot)
51
+ * @param {string} provider - Provider name (e.g., 'google', 'anthropic', 'openai')
52
+ * @returns {boolean} True if the format is supported by the provider
53
+ */
54
+ export function isFormatSupportedByProvider(extension, provider) {
55
+ // Validate extension parameter - must be a non-empty string without path separators
56
+ if (!extension || typeof extension !== 'string') {
57
+ return false;
58
+ }
59
+ // Sanitize: reject extensions containing path traversal characters
60
+ if (extension.includes('/') || extension.includes('\\') || extension.includes('..')) {
61
+ return false;
62
+ }
63
+
64
+ const ext = extension.toLowerCase();
65
+
66
+ // First check if it's a generally supported format
67
+ if (!SUPPORTED_IMAGE_EXTENSIONS.includes(ext)) {
68
+ return false;
69
+ }
70
+
71
+ // Handle null/undefined provider gracefully (treat as no restrictions)
72
+ if (!provider || typeof provider !== 'string') {
73
+ return true;
74
+ }
75
+
76
+ // Check provider-specific restrictions
77
+ const unsupportedFormats = PROVIDER_UNSUPPORTED_FORMATS[provider];
78
+ if (unsupportedFormats && unsupportedFormats.includes(ext)) {
79
+ return false;
80
+ }
81
+
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Get supported image extensions for a specific provider
87
+ * @param {string} provider - Provider name (e.g., 'google', 'anthropic', 'openai')
88
+ * @returns {string[]} Array of supported extensions for this provider
89
+ */
90
+ export function getSupportedExtensionsForProvider(provider) {
91
+ // Handle null/undefined/non-string provider gracefully (return all extensions)
92
+ if (!provider || typeof provider !== 'string') {
93
+ return [...SUPPORTED_IMAGE_EXTENSIONS];
94
+ }
95
+ const unsupportedFormats = PROVIDER_UNSUPPORTED_FORMATS[provider] || [];
96
+ return SUPPORTED_IMAGE_EXTENSIONS.filter(ext => !unsupportedFormats.includes(ext));
97
+ }
@@ -31,21 +31,39 @@ import { processXmlWithThinkingAndRecovery } from './xmlParsingUtils.js';
31
31
 
32
32
  // Create configured tool instances
33
33
  export function createTools(configOptions) {
34
- const tools = {
35
- searchTool: searchTool(configOptions),
36
- queryTool: queryTool(configOptions),
37
- extractTool: extractTool(configOptions),
38
- delegateTool: delegateTool(configOptions)
39
- };
34
+ const tools = {};
35
+
36
+ const isToolAllowed =
37
+ configOptions.isToolAllowed ||
38
+ ((toolName) => {
39
+ if (!configOptions.allowedTools) return true;
40
+ return configOptions.allowedTools.isEnabled(toolName);
41
+ });
42
+
43
+ // Core tools
44
+ if (isToolAllowed('search')) {
45
+ tools.searchTool = searchTool(configOptions);
46
+ }
47
+ if (isToolAllowed('query')) {
48
+ tools.queryTool = queryTool(configOptions);
49
+ }
50
+ if (isToolAllowed('extract')) {
51
+ tools.extractTool = extractTool(configOptions);
52
+ }
53
+ if (configOptions.enableDelegate && isToolAllowed('delegate')) {
54
+ tools.delegateTool = delegateTool(configOptions);
55
+ }
40
56
 
41
57
  // Add bash tool if enabled
42
- if (configOptions.enableBash) {
58
+ if (configOptions.enableBash && isToolAllowed('bash')) {
43
59
  tools.bashTool = bashTool(configOptions);
44
60
  }
45
61
 
46
62
  // Add edit and create tools if enabled
47
- if (configOptions.allowEdit) {
63
+ if (configOptions.allowEdit && isToolAllowed('edit')) {
48
64
  tools.editTool = editTool(configOptions);
65
+ }
66
+ if (configOptions.allowEdit && isToolAllowed('create')) {
49
67
  tools.createTool = createTool(configOptions);
50
68
  }
51
69
 
@@ -199,4 +217,3 @@ export function parseXmlToolCallWithThinking(xmlString, validTools) {
199
217
  // Otherwise, use the original parseXmlToolCall function to parse the cleaned XML string
200
218
  return parseXmlToolCall(cleanedXmlString, validTools);
201
219
  }
202
-