@probelabs/probe 0.6.0-rc225 → 0.6.0-rc226

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.
@@ -71,6 +71,7 @@ import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
71
71
  import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
72
72
  import { handleContextLimitError } from './contextCompactor.js';
73
73
  import { formatErrorForAI, ParameterError } from '../utils/error-types.js';
74
+ import { getCommonPrefix, toRelativePath, safeRealpath } from '../utils/path-validation.js';
74
75
  import { truncateIfNeeded, getMaxOutputTokens } from './outputTruncator.js';
75
76
  import { DelegationManager } from '../delegate.js';
76
77
  import {
@@ -269,8 +270,15 @@ export class ProbeAgent {
269
270
  this.allowedFolders = [process.cwd()];
270
271
  }
271
272
 
272
- // Working directory for resolving relative paths (separate from allowedFolders security)
273
- this.cwd = options.cwd || null;
273
+ // Compute workspace root as common prefix of all allowed folders
274
+ // This provides a single "root" for relative path resolution and default cwd
275
+ // IMPORTANT: workspaceRoot is NOT a security boundary - all security checks
276
+ // must be performed against this.allowedFolders, not workspaceRoot
277
+ this.workspaceRoot = getCommonPrefix(this.allowedFolders);
278
+
279
+ // Working directory for resolving relative paths
280
+ // If not explicitly provided, use workspace root for consistency
281
+ this.cwd = options.cwd || this.workspaceRoot;
274
282
 
275
283
  // API configuration
276
284
  this.clientApiProvider = options.provider || null;
@@ -289,6 +297,8 @@ export class ProbeAgent {
289
297
  console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
290
298
  console.log(`[DEBUG] Allow Edit (implement tool): ${this.allowEdit}`);
291
299
  console.log(`[DEBUG] Search delegation enabled: ${this.searchDelegate}`);
300
+ console.log(`[DEBUG] Workspace root: ${this.workspaceRoot}`);
301
+ console.log(`[DEBUG] Working directory (cwd): ${this.cwd}`);
292
302
  }
293
303
 
294
304
  // Initialize tools
@@ -732,8 +742,9 @@ export class ProbeAgent {
732
742
  const configOptions = {
733
743
  sessionId: this.sessionId,
734
744
  debug: this.debug,
735
- // Use explicit cwd if set, otherwise fall back to first allowed folder
736
- cwd: this.cwd || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd()),
745
+ // Use cwd (which defaults to workspaceRoot in constructor)
746
+ cwd: this.cwd,
747
+ workspaceRoot: this.workspaceRoot,
737
748
  allowedFolders: this.allowedFolders,
738
749
  outline: this.outline,
739
750
  searchDelegate: this.searchDelegate,
@@ -1612,7 +1623,8 @@ export class ProbeAgent {
1612
1623
  }
1613
1624
 
1614
1625
  // Security validation: check if path is within any allowed directory
1615
- // Use normalize() after resolve() to handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1626
+ // Use safeRealpath() to resolve symlinks and handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1627
+ // This prevents symlink bypass attacks (e.g., /tmp -> /private/tmp on macOS)
1616
1628
  const allowedDirs = this.allowedFolders && this.allowedFolders.length > 0 ? this.allowedFolders : [process.cwd()];
1617
1629
 
1618
1630
  let absolutePath;
@@ -1620,20 +1632,20 @@ export class ProbeAgent {
1620
1632
 
1621
1633
  // If absolute path, check if it's within any allowed directory
1622
1634
  if (isAbsolute(imagePath)) {
1623
- // Normalize to resolve any '..' sequences
1624
- absolutePath = normalize(resolve(imagePath));
1635
+ // Use safeRealpath to resolve symlinks for security
1636
+ absolutePath = safeRealpath(resolve(imagePath));
1625
1637
  isPathAllowed = allowedDirs.some(dir => {
1626
- const normalizedDir = normalize(resolve(dir));
1638
+ const resolvedDir = safeRealpath(dir);
1627
1639
  // Ensure the path is within the allowed directory (add separator to prevent prefix attacks)
1628
- return absolutePath === normalizedDir || absolutePath.startsWith(normalizedDir + sep);
1640
+ return absolutePath === resolvedDir || absolutePath.startsWith(resolvedDir + sep);
1629
1641
  });
1630
1642
  } else {
1631
1643
  // For relative paths, try resolving against each allowed directory
1632
1644
  for (const dir of allowedDirs) {
1633
- const normalizedDir = normalize(resolve(dir));
1634
- const resolvedPath = normalize(resolve(dir, imagePath));
1645
+ const resolvedDir = safeRealpath(dir);
1646
+ const resolvedPath = safeRealpath(resolve(dir, imagePath));
1635
1647
  // Ensure the resolved path is within the allowed directory
1636
- if (resolvedPath === normalizedDir || resolvedPath.startsWith(normalizedDir + sep)) {
1648
+ if (resolvedPath === resolvedDir || resolvedPath.startsWith(resolvedDir + sep)) {
1637
1649
  absolutePath = resolvedPath;
1638
1650
  isPathAllowed = true;
1639
1651
  break;
@@ -1870,7 +1882,8 @@ export class ProbeAgent {
1870
1882
  return this.architectureContext;
1871
1883
  }
1872
1884
 
1873
- const rootDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
1885
+ // Use workspaceRoot for consistent path handling
1886
+ const rootDirectory = this.workspaceRoot || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd());
1874
1887
  const configuredName =
1875
1888
  typeof this.architectureFileName === 'string' ? this.architectureFileName.trim() : '';
1876
1889
  const hasConfiguredName = !!configuredName;
@@ -2028,6 +2041,10 @@ export class ProbeAgent {
2028
2041
  }
2029
2042
 
2030
2043
  _getSkillsRepoRoot() {
2044
+ // Use workspaceRoot for consistent path handling
2045
+ if (this.workspaceRoot) {
2046
+ return resolve(this.workspaceRoot);
2047
+ }
2031
2048
  if (this.allowedFolders && this.allowedFolders.length > 0) {
2032
2049
  return resolve(this.allowedFolders[0]);
2033
2050
  }
@@ -2108,7 +2125,7 @@ ${extractGuidance}
2108
2125
  // Add repository structure if available
2109
2126
  if (this.fileList) {
2110
2127
  systemPrompt += `\n\n# Repository Structure\n`;
2111
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2128
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
2112
2129
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
2113
2130
  systemPrompt += '```\n' + this.fileList + '\n```\n';
2114
2131
  }
@@ -2170,7 +2187,7 @@ ${extractGuidance}
2170
2187
  // Add repository structure if available
2171
2188
  if (this.fileList) {
2172
2189
  systemPrompt += `\n\n# Repository Structure\n`;
2173
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2190
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
2174
2191
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
2175
2192
  systemPrompt += '```\n' + this.fileList + '\n```\n';
2176
2193
  }
@@ -2484,10 +2501,29 @@ Follow these instructions carefully:
2484
2501
  }
2485
2502
  }
2486
2503
 
2487
- // Add folder information
2488
- const searchDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
2504
+ // Add folder information using workspace root and relative paths
2505
+ const searchDirectory = this.workspaceRoot;
2489
2506
  if (this.debug) {
2490
- console.log(`[DEBUG] Generating file list for base directory: ${searchDirectory}...`);
2507
+ console.log(`[DEBUG] Generating file list for workspace root: ${searchDirectory}...`);
2508
+ }
2509
+
2510
+ // Convert allowed folders to relative paths for cleaner AI context
2511
+ // Add ./ prefix to make it clear these are relative paths
2512
+ const relativeWorkspaces = this.allowedFolders.map(f => {
2513
+ const rel = toRelativePath(f, this.workspaceRoot);
2514
+ // Add ./ prefix if not already starting with . and not an absolute path
2515
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2516
+ return './' + rel;
2517
+ }
2518
+ return rel;
2519
+ }).filter(f => f && f !== '.');
2520
+
2521
+ // Describe available paths in a user-friendly way
2522
+ let workspaceDesc;
2523
+ if (relativeWorkspaces.length === 0) {
2524
+ workspaceDesc = '. (current directory)';
2525
+ } else {
2526
+ workspaceDesc = relativeWorkspaces.join(', ');
2491
2527
  }
2492
2528
 
2493
2529
  try {
@@ -2495,15 +2531,15 @@ Follow these instructions carefully:
2495
2531
  directory: searchDirectory,
2496
2532
  maxFiles: 100,
2497
2533
  respectGitignore: !process.env.PROBE_NO_GITIGNORE || process.env.PROBE_NO_GITIGNORE === '',
2498
- cwd: process.cwd()
2534
+ cwd: this.workspaceRoot
2499
2535
  });
2500
2536
 
2501
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2537
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2502
2538
  } catch (error) {
2503
2539
  if (this.debug) {
2504
2540
  console.log(`[DEBUG] Could not generate file list: ${error.message}`);
2505
2541
  }
2506
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\n`;
2542
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\n`;
2507
2543
  }
2508
2544
 
2509
2545
  // Add architecture context if available
@@ -2511,7 +2547,15 @@ Follow these instructions carefully:
2511
2547
  systemMessage += this.getArchitectureSection();
2512
2548
 
2513
2549
  if (this.allowedFolders.length > 0) {
2514
- systemMessage += `\n**Important**: For security reasons, you can only search within these allowed folders: ${this.allowedFolders.join(', ')}\n\n`;
2550
+ const relativeAllowed = this.allowedFolders.map(f => {
2551
+ const rel = toRelativePath(f, this.workspaceRoot);
2552
+ // Add ./ prefix if not already starting with . and not an absolute path
2553
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2554
+ return './' + rel;
2555
+ }
2556
+ return rel;
2557
+ });
2558
+ systemMessage += `\n**Important**: For security reasons, you can only access these paths: ${relativeAllowed.join(', ')}\n\n`;
2515
2559
  }
2516
2560
 
2517
2561
  return systemMessage;
@@ -3234,6 +3278,8 @@ Follow these instructions carefully:
3234
3278
  console.error(`[DEBUG] ========================================\n`);
3235
3279
  }
3236
3280
 
3281
+ // Add assistant message with tool call (matching native tool pattern)
3282
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3237
3283
  currentMessages.push({ role: 'user', content: `<tool_result>\n${toolResultContent}\n</tool_result>` });
3238
3284
  } catch (error) {
3239
3285
  // Record MCP tool end event (failure)
@@ -3257,24 +3303,27 @@ Follow these instructions carefully:
3257
3303
 
3258
3304
  // Format error with structured information for AI
3259
3305
  const errorXml = formatErrorForAI(error);
3306
+ // Add assistant message with tool call (matching native tool pattern)
3307
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3260
3308
  currentMessages.push({ role: 'user', content: `<tool_result>\n${errorXml}\n</tool_result>` });
3261
3309
  }
3262
3310
  } else if (this.toolImplementations[toolName]) {
3263
3311
  // Execute native tool
3264
3312
  try {
3265
3313
  // Add sessionId and workingDirectory to params for tool execution
3266
- // Validate and resolve workingDirectory
3267
- // Priority: explicit cwd > first allowed folder > process.cwd()
3268
- let resolvedWorkingDirectory = this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3314
+ // Validate and resolve workingDirectory using safeRealpath for symlink security
3315
+ // Consistent fallback chain: workspaceRoot > cwd > allowedFolders[0] > process.cwd()
3316
+ let resolvedWorkingDirectory = this.workspaceRoot || this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3269
3317
  if (params.workingDirectory) {
3270
3318
  // Resolve relative paths against the current working directory context, not process.cwd()
3271
- const requestedDir = isAbsolute(params.workingDirectory)
3319
+ // Use safeRealpath to resolve symlinks and prevent bypass attacks
3320
+ const requestedDir = safeRealpath(isAbsolute(params.workingDirectory)
3272
3321
  ? resolve(params.workingDirectory)
3273
- : resolve(resolvedWorkingDirectory, params.workingDirectory);
3322
+ : resolve(resolvedWorkingDirectory, params.workingDirectory));
3274
3323
  // Check if the requested directory is within allowed folders
3275
3324
  const isWithinAllowed = !this.allowedFolders || this.allowedFolders.length === 0 ||
3276
3325
  this.allowedFolders.some(folder => {
3277
- const resolvedFolder = resolve(folder);
3326
+ const resolvedFolder = safeRealpath(folder);
3278
3327
  return requestedDir === resolvedFolder || requestedDir.startsWith(resolvedFolder + sep);
3279
3328
  });
3280
3329
  if (isWithinAllowed) {
@@ -3887,7 +3936,7 @@ Convert your previous response content into actual JSON data that follows this s
3887
3936
 
3888
3937
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
3889
3938
  debug: this.debug,
3890
- path: this.allowedFolders[0],
3939
+ path: this.workspaceRoot || this.allowedFolders[0],
3891
3940
  provider: this.clientApiProvider,
3892
3941
  model: this.model,
3893
3942
  tracer: this.tracer
@@ -3977,7 +4026,7 @@ Convert your previous response content into actual JSON data that follows this s
3977
4026
 
3978
4027
  const { JsonFixingAgent } = await import('./schemaUtils.js');
3979
4028
  const jsonFixer = new JsonFixingAgent({
3980
- path: this.allowedFolders[0],
4029
+ path: this.workspaceRoot || this.allowedFolders[0],
3981
4030
  provider: this.clientApiProvider,
3982
4031
  model: this.model,
3983
4032
  debug: this.debug,
@@ -4065,7 +4114,7 @@ Convert your previous response content into actual JSON data that follows this s
4065
4114
 
4066
4115
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
4067
4116
  debug: this.debug,
4068
- path: this.allowedFolders[0],
4117
+ path: this.workspaceRoot || this.allowedFolders[0],
4069
4118
  provider: this.clientApiProvider,
4070
4119
  model: this.model,
4071
4120
  tracer: this.tracer
@@ -4221,7 +4270,7 @@ Convert your previous response content into actual JSON data that follows this s
4221
4270
 
4222
4271
  const finalMermaidValidation = await validateAndFixMermaidResponse(finalResult, {
4223
4272
  debug: this.debug,
4224
- path: this.allowedFolders[0],
4273
+ path: this.workspaceRoot || this.allowedFolders[0],
4225
4274
  provider: this.clientApiProvider,
4226
4275
  model: this.model,
4227
4276
  tracer: this.tracer
@@ -4419,7 +4468,7 @@ Convert your previous response content into actual JSON data that follows this s
4419
4468
  allowEdit: this.allowEdit,
4420
4469
  enableDelegate: this.enableDelegate,
4421
4470
  architectureFileName: this.architectureFileName,
4422
- path: this.allowedFolders[0], // Use first allowed folder as primary path
4471
+ // Pass allowedFolders which will recompute workspaceRoot correctly
4423
4472
  allowedFolders: [...this.allowedFolders],
4424
4473
  cwd: this.cwd, // Preserve explicit working directory
4425
4474
  provider: this.clientApiProvider,