@probelabs/probe 0.6.0-rc224 → 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.
Files changed (33) hide show
  1. package/bin/binaries/probe-v0.6.0-rc226-aarch64-apple-darwin.tar.gz +0 -0
  2. package/bin/binaries/probe-v0.6.0-rc226-aarch64-unknown-linux-musl.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc226-x86_64-apple-darwin.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc226-x86_64-pc-windows-msvc.zip +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc226-x86_64-unknown-linux-musl.tar.gz +0 -0
  6. package/build/agent/ProbeAgent.js +361 -36
  7. package/build/agent/index.js +570 -57
  8. package/build/agent/mcp/xmlBridge.js +10 -7
  9. package/build/agent/simpleTelemetry.js +198 -0
  10. package/build/agent/tools.js +8 -5
  11. package/build/tools/analyzeAll.js +6 -1
  12. package/build/tools/bash.js +18 -3
  13. package/build/tools/edit.js +19 -10
  14. package/build/tools/vercel.js +17 -7
  15. package/build/utils/path-validation.js +148 -1
  16. package/cjs/agent/ProbeAgent.cjs +392 -56
  17. package/cjs/agent/simpleTelemetry.cjs +177 -0
  18. package/cjs/index.cjs +569 -56
  19. package/package.json +1 -1
  20. package/src/agent/ProbeAgent.js +361 -36
  21. package/src/agent/mcp/xmlBridge.js +10 -7
  22. package/src/agent/simpleTelemetry.js +198 -0
  23. package/src/agent/tools.js +8 -5
  24. package/src/tools/analyzeAll.js +6 -1
  25. package/src/tools/bash.js +18 -3
  26. package/src/tools/edit.js +19 -10
  27. package/src/tools/vercel.js +17 -7
  28. package/src/utils/path-validation.js +148 -1
  29. package/bin/binaries/probe-v0.6.0-rc224-aarch64-apple-darwin.tar.gz +0 -0
  30. package/bin/binaries/probe-v0.6.0-rc224-aarch64-unknown-linux-musl.tar.gz +0 -0
  31. package/bin/binaries/probe-v0.6.0-rc224-x86_64-apple-darwin.tar.gz +0 -0
  32. package/bin/binaries/probe-v0.6.0-rc224-x86_64-pc-windows-msvc.zip +0 -0
  33. package/bin/binaries/probe-v0.6.0-rc224-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -321,14 +321,17 @@ export function parseHybridXmlToolCall(xmlString, nativeTools = [], mcpBridge =
321
321
  // This includes thinking tag removal and attempt_complete recovery logic
322
322
  const nativeResult = parseNativeXmlToolWithThinking(xmlString, nativeTools);
323
323
  if (nativeResult) {
324
- return { ...nativeResult, type: 'native' };
324
+ const { thinkingContent, ...rest } = nativeResult;
325
+ return { ...rest, type: 'native', thinkingContent };
325
326
  }
326
327
 
327
328
  // Then try MCP tools if bridge is available
328
329
  if (mcpBridge) {
329
330
  const mcpResult = parseXmlMcpToolCall(xmlString, mcpBridge.getToolNames());
330
331
  if (mcpResult) {
331
- return { ...mcpResult, type: 'mcp' };
332
+ // Extract thinking content for MCP tools as well
333
+ const { thinkingContent } = processXmlWithThinkingAndRecovery(xmlString, []);
334
+ return { ...mcpResult, type: 'mcp', thinkingContent };
332
335
  }
333
336
  }
334
337
 
@@ -344,18 +347,18 @@ export function parseHybridXmlToolCall(xmlString, nativeTools = [], mcpBridge =
344
347
  */
345
348
  function parseNativeXmlToolWithThinking(xmlString, validTools) {
346
349
  // Use the shared processing logic
347
- const { cleanedXmlString, recoveryResult } = processXmlWithThinkingAndRecovery(xmlString, validTools);
348
-
349
- // If recovery found an attempt_complete pattern, return it
350
+ const { cleanedXmlString, recoveryResult, thinkingContent } = processXmlWithThinkingAndRecovery(xmlString, validTools);
351
+
352
+ // If recovery found an attempt_complete pattern, return it with thinking content
350
353
  if (recoveryResult) {
351
- return recoveryResult;
354
+ return { ...recoveryResult, thinkingContent };
352
355
  }
353
356
 
354
357
  // Use the original parseNativeXmlTool function to parse the cleaned XML string
355
358
  for (const toolName of validTools) {
356
359
  const result = parseNativeXmlTool(cleanedXmlString, toolName);
357
360
  if (result) {
358
- return result;
361
+ return { ...result, thinkingContent };
359
362
  }
360
363
  }
361
364
 
@@ -264,6 +264,204 @@ export class SimpleAppTracer {
264
264
  }
265
265
  }
266
266
 
267
+ /**
268
+ * Hash content for deduplication/comparison purposes
269
+ * @param {string} content - The content to hash
270
+ * @returns {string} - Hex string hash
271
+ */
272
+ hashContent(content) {
273
+ let hash = 0;
274
+ const len = Math.min(content.length, 1000);
275
+ for (let i = 0; i < len; i++) {
276
+ hash = ((hash << 5) - hash) + content.charCodeAt(i);
277
+ hash |= 0; // Convert to 32-bit integer
278
+ }
279
+ return hash.toString(16);
280
+ }
281
+
282
+ /**
283
+ * Record a conversation turn (assistant response or tool result)
284
+ * @param {string} role - The role (assistant, tool_result)
285
+ * @param {string} content - The turn content
286
+ * @param {Object} metadata - Additional metadata
287
+ */
288
+ recordConversationTurn(role, content, metadata = {}) {
289
+ if (!this.isEnabled()) return;
290
+
291
+ this.addEvent(`conversation.turn.${role}`, {
292
+ 'session.id': this.sessionId,
293
+ 'conversation.role': role,
294
+ 'conversation.content': content.substring(0, 10000),
295
+ 'conversation.content.length': content.length,
296
+ 'conversation.content.hash': this.hashContent(content),
297
+ ...metadata
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Record error events with classification
303
+ * @param {string} errorType - The type of error (wrapped_tool, unrecognized_tool, no_tool_call, circuit_breaker, etc.)
304
+ * @param {Object} errorDetails - Error details including message, stack, context
305
+ */
306
+ recordErrorEvent(errorType, errorDetails = {}) {
307
+ if (!this.isEnabled()) return;
308
+
309
+ this.addEvent(`error.${errorType}`, {
310
+ 'session.id': this.sessionId,
311
+ 'error.type': errorType,
312
+ 'error.message': errorDetails.message?.substring(0, 1000) || null,
313
+ 'error.stack': errorDetails.stack?.substring(0, 2000) || null,
314
+ 'error.recoverable': errorDetails.recoverable ?? true,
315
+ 'error.context': JSON.stringify(errorDetails.context || {}).substring(0, 1000),
316
+ ...Object.fromEntries(
317
+ Object.entries(errorDetails)
318
+ .filter(([k]) => !['message', 'stack', 'context', 'recoverable'].includes(k))
319
+ .map(([k, v]) => [`error.${k}`, v])
320
+ )
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Record AI thinking/reasoning content
326
+ * @param {string} thinkingContent - The thinking content from AI response
327
+ * @param {Object} metadata - Additional metadata
328
+ */
329
+ recordThinkingContent(thinkingContent, metadata = {}) {
330
+ if (!this.isEnabled() || !thinkingContent) return;
331
+
332
+ this.addEvent('ai.thinking', {
333
+ 'session.id': this.sessionId,
334
+ 'ai.thinking.content': thinkingContent.substring(0, 50000),
335
+ 'ai.thinking.length': thinkingContent.length,
336
+ 'ai.thinking.hash': this.hashContent(thinkingContent),
337
+ ...metadata
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Record AI tool call decision
343
+ * @param {string} toolName - The tool name AI decided to call
344
+ * @param {Object} params - The parameters AI provided
345
+ * @param {Object} metadata - Additional metadata
346
+ */
347
+ recordToolDecision(toolName, params, metadata = {}) {
348
+ if (!this.isEnabled()) return;
349
+
350
+ this.addEvent('ai.tool_decision', {
351
+ 'session.id': this.sessionId,
352
+ 'ai.tool_decision.name': toolName,
353
+ 'ai.tool_decision.params': JSON.stringify(params || {}).substring(0, 2000),
354
+ ...metadata
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Record tool result after execution
360
+ * @param {string} toolName - The tool that was executed
361
+ * @param {string|Object} result - The tool result
362
+ * @param {boolean} success - Whether the tool succeeded
363
+ * @param {number} durationMs - Execution duration in milliseconds
364
+ * @param {Object} metadata - Additional metadata
365
+ */
366
+ recordToolResult(toolName, result, success, durationMs, metadata = {}) {
367
+ if (!this.isEnabled()) return;
368
+
369
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
370
+ this.addEvent('tool.result', {
371
+ 'session.id': this.sessionId,
372
+ 'tool.name': toolName,
373
+ 'tool.result': resultStr.substring(0, 10000),
374
+ 'tool.result.length': resultStr.length,
375
+ 'tool.result.hash': this.hashContent(resultStr),
376
+ 'tool.duration_ms': durationMs,
377
+ 'tool.success': success,
378
+ ...metadata
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Record MCP tool execution start
384
+ * @param {string} toolName - MCP tool name
385
+ * @param {string} serverName - MCP server name
386
+ * @param {Object} params - Tool parameters
387
+ * @param {Object} metadata - Additional metadata
388
+ */
389
+ recordMcpToolStart(toolName, serverName, params, metadata = {}) {
390
+ if (!this.isEnabled()) return;
391
+
392
+ this.addEvent('mcp.tool.start', {
393
+ 'session.id': this.sessionId,
394
+ 'mcp.tool.name': toolName,
395
+ 'mcp.tool.server': serverName || 'unknown',
396
+ 'mcp.tool.params': JSON.stringify(params || {}).substring(0, 2000),
397
+ ...metadata
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Record MCP tool execution end
403
+ * @param {string} toolName - MCP tool name
404
+ * @param {string} serverName - MCP server name
405
+ * @param {string|Object} result - Tool result
406
+ * @param {boolean} success - Whether succeeded
407
+ * @param {number} durationMs - Execution duration
408
+ * @param {string} errorMessage - Error message if failed
409
+ * @param {Object} metadata - Additional metadata
410
+ */
411
+ recordMcpToolEnd(toolName, serverName, result, success, durationMs, errorMessage = null, metadata = {}) {
412
+ if (!this.isEnabled()) return;
413
+
414
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result || '');
415
+ this.addEvent('mcp.tool.end', {
416
+ 'session.id': this.sessionId,
417
+ 'mcp.tool.name': toolName,
418
+ 'mcp.tool.server': serverName || 'unknown',
419
+ 'mcp.tool.result': resultStr.substring(0, 10000),
420
+ 'mcp.tool.result.length': resultStr.length,
421
+ 'mcp.tool.duration_ms': durationMs,
422
+ 'mcp.tool.success': success,
423
+ 'mcp.tool.error': errorMessage,
424
+ ...metadata
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Record iteration lifecycle event
430
+ * @param {string} eventType - start or end
431
+ * @param {number} iteration - Iteration number
432
+ * @param {Object} data - Additional data
433
+ */
434
+ recordIterationEvent(eventType, iteration, data = {}) {
435
+ if (!this.isEnabled()) return;
436
+
437
+ this.addEvent(`iteration.${eventType}`, {
438
+ 'session.id': this.sessionId,
439
+ 'iteration': iteration,
440
+ ...data
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Record per-turn token breakdown
446
+ * @param {number} iteration - Iteration number
447
+ * @param {Object} tokenData - Token metrics
448
+ */
449
+ recordTokenTurn(iteration, tokenData = {}) {
450
+ if (!this.isEnabled()) return;
451
+
452
+ this.addEvent('tokens.turn', {
453
+ 'session.id': this.sessionId,
454
+ 'iteration': iteration,
455
+ 'tokens.input': tokenData.inputTokens || 0,
456
+ 'tokens.output': tokenData.outputTokens || 0,
457
+ 'tokens.total': (tokenData.inputTokens || 0) + (tokenData.outputTokens || 0),
458
+ 'tokens.cache_read': tokenData.cacheReadTokens || 0,
459
+ 'tokens.cache_write': tokenData.cacheWriteTokens || 0,
460
+ 'tokens.context_used': tokenData.contextTokens || 0,
461
+ 'tokens.context_remaining': tokenData.maxContextTokens ? (tokenData.maxContextTokens - (tokenData.contextTokens || 0)) : null
462
+ });
463
+ }
464
+
267
465
  async withSpan(spanName, fn, attributes = {}) {
268
466
  if (!this.isEnabled()) {
269
467
  return fn();
@@ -270,13 +270,16 @@ User: Analyze the diagram in docs/architecture.svg
270
270
  */
271
271
  export function parseXmlToolCallWithThinking(xmlString, validTools) {
272
272
  // Use the shared processing logic
273
- const { cleanedXmlString, recoveryResult } = processXmlWithThinkingAndRecovery(xmlString, validTools);
274
-
275
- // If recovery found an attempt_complete pattern, return it
273
+ const { cleanedXmlString, recoveryResult, thinkingContent } = processXmlWithThinkingAndRecovery(xmlString, validTools);
274
+
275
+ // If recovery found an attempt_complete pattern, return it with thinking content
276
276
  if (recoveryResult) {
277
- return recoveryResult;
277
+ return { ...recoveryResult, thinkingContent };
278
278
  }
279
279
 
280
280
  // Otherwise, use the original parseXmlToolCall function to parse the cleaned XML string
281
- return parseXmlToolCall(cleanedXmlString, validTools);
281
+ const toolCall = parseXmlToolCall(cleanedXmlString, validTools);
282
+
283
+ // Return tool call with thinking content attached
284
+ return toolCall ? { ...toolCall, thinkingContent } : null;
282
285
  }
@@ -514,6 +514,7 @@ export async function analyzeAll(options) {
514
514
  sessionId,
515
515
  debug = false,
516
516
  cwd,
517
+ workspaceRoot,
517
518
  allowedFolders,
518
519
  provider,
519
520
  model,
@@ -527,10 +528,14 @@ export async function analyzeAll(options) {
527
528
  throw new Error('The "question" parameter is required.');
528
529
  }
529
530
 
531
+ // Use workspaceRoot (computed common prefix) for consistent path handling
532
+ // Consistent fallback chain: workspaceRoot > cwd > allowedFolders[0] > path
533
+ const effectiveWorkspaceRoot = workspaceRoot || cwd || allowedFolders?.[0] || path;
534
+
530
535
  const delegateOptions = {
531
536
  debug,
532
537
  sessionId,
533
- path: allowedFolders?.[0] || cwd || path,
538
+ path: effectiveWorkspaceRoot,
534
539
  allowedFolders,
535
540
  provider,
536
541
  model,
@@ -7,6 +7,7 @@ import { tool } from 'ai';
7
7
  import { resolve, isAbsolute, sep } from 'path';
8
8
  import { BashPermissionChecker } from '../agent/bashPermissions.js';
9
9
  import { executeBashCommand, formatExecutionResult, validateExecutionOptions } from '../agent/bashExecutor.js';
10
+ import { toRelativePath, safeRealpath } from '../utils/path-validation.js';
10
11
 
11
12
  /**
12
13
  * Bash tool generator
@@ -32,9 +33,14 @@ export const bashTool = (options = {}) => {
32
33
  debug = false,
33
34
  cwd,
34
35
  allowedFolders = [],
36
+ workspaceRoot: providedWorkspaceRoot,
35
37
  tracer = null
36
38
  } = options;
37
39
 
40
+ // Compute workspaceRoot with proper fallback chain
41
+ // Priority: explicit workspaceRoot > cwd > allowedFolders[0] > process.cwd()
42
+ const workspaceRoot = providedWorkspaceRoot || cwd || (allowedFolders.length > 0 && allowedFolders[0]) || process.cwd();
43
+
38
44
  // Create permission checker with tracer for telemetry
39
45
  const permissionChecker = new BashPermissionChecker({
40
46
  allow: bashConfig.allow,
@@ -46,6 +52,7 @@ export const bashTool = (options = {}) => {
46
52
  });
47
53
 
48
54
  // Determine default working directory
55
+ // Priority: explicit bashConfig.workingDirectory > cwd (which defaults to workspaceRoot) > fallback
49
56
  const getDefaultWorkingDirectory = () => {
50
57
  if (bashConfig.workingDirectory) {
51
58
  return bashConfig.workingDirectory;
@@ -53,6 +60,10 @@ export const bashTool = (options = {}) => {
53
60
  if (cwd) {
54
61
  return cwd;
55
62
  }
63
+ // Use workspaceRoot (computed common prefix) for consistency with other tools
64
+ if (workspaceRoot) {
65
+ return workspaceRoot;
66
+ }
56
67
  if (allowedFolders && allowedFolders.length > 0) {
57
68
  return allowedFolders[0];
58
69
  }
@@ -154,16 +165,20 @@ For code exploration, try these safe alternatives:
154
165
 
155
166
  // Validate working directory is within allowed folders if specified
156
167
  if (allowedFolders && allowedFolders.length > 0) {
157
- const resolvedWorkingDir = resolve(workingDir);
168
+ // Use safeRealpath to resolve symlinks for security
169
+ // This prevents symlink bypass attacks (e.g., /tmp -> /private/tmp on macOS)
170
+ const resolvedWorkingDir = safeRealpath(workingDir);
158
171
  const isAllowed = allowedFolders.some(folder => {
159
- const resolvedFolder = resolve(folder);
172
+ const resolvedFolder = safeRealpath(folder);
160
173
  // Use exact match OR startsWith with separator to prevent bypass attacks
161
174
  // e.g., '/tmp-malicious' should NOT match allowed folder '/tmp'
162
175
  return resolvedWorkingDir === resolvedFolder || resolvedWorkingDir.startsWith(resolvedFolder + sep);
163
176
  });
164
177
 
165
178
  if (!isAllowed) {
166
- return `Error: Working directory "${workingDir}" is not within allowed folders: ${allowedFolders.join(', ')}`;
179
+ const relativeDir = toRelativePath(workingDir, workspaceRoot);
180
+ const relativeAllowed = allowedFolders.map(f => toRelativePath(f, workspaceRoot));
181
+ return `Error: Working directory "${relativeDir}" is not within allowed folders: ${relativeAllowed.join(', ')}`;
167
182
  }
168
183
  }
169
184
 
@@ -7,6 +7,7 @@ import { tool } from 'ai';
7
7
  import { promises as fs } from 'fs';
8
8
  import { dirname, resolve, isAbsolute, sep } from 'path';
9
9
  import { existsSync } from 'fs';
10
+ import { toRelativePath, safeRealpath } from '../utils/path-validation.js';
10
11
 
11
12
  /**
12
13
  * Validates that a path is within allowed directories
@@ -17,15 +18,18 @@ import { existsSync } from 'fs';
17
18
  function isPathAllowed(filePath, allowedFolders) {
18
19
  if (!allowedFolders || allowedFolders.length === 0) {
19
20
  // If no restrictions, allow current directory and below
20
- const resolvedPath = resolve(filePath);
21
- const cwd = resolve(process.cwd());
21
+ // Use safeRealpath to resolve symlinks for security
22
+ const resolvedPath = safeRealpath(filePath);
23
+ const cwd = safeRealpath(process.cwd());
22
24
  // Ensure proper path separator to prevent path traversal
23
25
  return resolvedPath === cwd || resolvedPath.startsWith(cwd + sep);
24
26
  }
25
27
 
26
- const resolvedPath = resolve(filePath);
28
+ // Use safeRealpath to resolve symlinks for security
29
+ // This prevents symlink bypass attacks (e.g., /tmp -> /private/tmp on macOS)
30
+ const resolvedPath = safeRealpath(filePath);
27
31
  return allowedFolders.some(folder => {
28
- const allowedPath = resolve(folder);
32
+ const allowedPath = safeRealpath(folder);
29
33
  // Ensure proper path separator to prevent path traversal
30
34
  return resolvedPath === allowedPath || resolvedPath.startsWith(allowedPath + sep);
31
35
  });
@@ -37,10 +41,13 @@ function isPathAllowed(filePath, allowedFolders) {
37
41
  * @returns {Object} Parsed configuration
38
42
  */
39
43
  function parseFileToolOptions(options = {}) {
44
+ const allowedFolders = options.allowedFolders || [];
40
45
  return {
41
46
  debug: options.debug || false,
42
- allowedFolders: options.allowedFolders || [],
43
- cwd: options.cwd
47
+ allowedFolders,
48
+ cwd: options.cwd,
49
+ // Consistent fallback chain: workspaceRoot > cwd > allowedFolders[0] > process.cwd()
50
+ workspaceRoot: options.workspaceRoot || options.cwd || (allowedFolders.length > 0 && allowedFolders[0]) || process.cwd()
44
51
  };
45
52
  }
46
53
 
@@ -54,7 +61,7 @@ function parseFileToolOptions(options = {}) {
54
61
  * @returns {Object} Configured edit tool
55
62
  */
56
63
  export const editTool = (options = {}) => {
57
- const { debug, allowedFolders, cwd } = parseFileToolOptions(options);
64
+ const { debug, allowedFolders, cwd, workspaceRoot } = parseFileToolOptions(options);
58
65
 
59
66
  return tool({
60
67
  name: 'edit',
@@ -119,7 +126,8 @@ Important:
119
126
 
120
127
  // Check if path is allowed
121
128
  if (!isPathAllowed(resolvedPath, allowedFolders)) {
122
- return `Error editing file: Permission denied - ${file_path} is outside allowed directories`;
129
+ const relativePath = toRelativePath(resolvedPath, workspaceRoot);
130
+ return `Error editing file: Permission denied - ${relativePath} is outside allowed directories`;
123
131
  }
124
132
 
125
133
  // Check if file exists
@@ -186,7 +194,7 @@ Important:
186
194
  * @returns {Object} Configured create tool
187
195
  */
188
196
  export const createTool = (options = {}) => {
189
- const { debug, allowedFolders, cwd } = parseFileToolOptions(options);
197
+ const { debug, allowedFolders, cwd, workspaceRoot } = parseFileToolOptions(options);
190
198
 
191
199
  return tool({
192
200
  name: 'create',
@@ -243,7 +251,8 @@ Important:
243
251
 
244
252
  // Check if path is allowed
245
253
  if (!isPathAllowed(resolvedPath, allowedFolders)) {
246
- return `Error creating file: Permission denied - ${file_path} is outside allowed directories`;
254
+ const relativePath = toRelativePath(resolvedPath, workspaceRoot);
255
+ return `Error creating file: Permission denied - ${relativePath} is outside allowed directories`;
247
256
  }
248
257
 
249
258
  // Check if file exists
@@ -471,7 +471,7 @@ export const extractTool = (options = {}) => {
471
471
  * @returns {Object} Configured delegate tool
472
472
  */
473
473
  export const delegateTool = (options = {}) => {
474
- const { debug = false, timeout = 300, cwd, allowedFolders, enableBash = false, bashConfig, architectureFileName, enableMcp = false, mcpConfig = null, mcpConfigPath = null, delegationManager = null } = options;
474
+ const { debug = false, timeout = 300, cwd, allowedFolders, workspaceRoot, enableBash = false, bashConfig, architectureFileName, enableMcp = false, mcpConfig = null, mcpConfigPath = null, delegationManager = null } = options;
475
475
 
476
476
  return tool({
477
477
  name: 'delegate',
@@ -521,7 +521,7 @@ export const delegateTool = (options = {}) => {
521
521
  // NOTE: Delegation intentionally uses DIFFERENT priority than other tools.
522
522
  //
523
523
  // Other tools (search, extract, query, bash) use: cwd || allowedFolders[0]
524
- // Delegation uses: path || allowedFolders[0] || cwd
524
+ // Delegation uses: path || workspaceRoot || cwd
525
525
  //
526
526
  // This is intentional because:
527
527
  // - Other tools operate within the parent's navigation context (cwd is correct)
@@ -529,10 +529,15 @@ export const delegateTool = (options = {}) => {
529
529
  // - Using parent's cwd would cause "path doubling" (Issue #348) where paths like
530
530
  // /workspace/project/src/internal/build/src/internal/build/file.go get constructed
531
531
  //
532
- // The workspace root (allowedFolders[0]) is the security boundary and correct base
533
- // for subagent operations. Parent navigation context should not leak to subagents.
534
- const workspaceRoot = allowedFolders && allowedFolders[0];
535
- const effectivePath = path || workspaceRoot || cwd;
532
+ // The workspace root (computed as common prefix of allowedFolders) is the security
533
+ // boundary and correct base for subagent operations. Parent navigation context
534
+ // should not leak to subagents.
535
+ //
536
+ // NOTE: This priority (workspaceRoot > allowedFolders[0], excluding cwd) is INTENTIONALLY
537
+ // different from other tools (bashTool uses workspaceRoot > cwd > allowedFolders[0]).
538
+ // This prevents parent's navigation state from leaking to subagents.
539
+ const effectiveWorkspaceRoot = workspaceRoot || (allowedFolders && allowedFolders[0]);
540
+ const effectivePath = path || effectiveWorkspaceRoot || cwd;
536
541
 
537
542
  if (debug) {
538
543
  console.error(`Executing delegate with task: "${task.substring(0, 100)}${task.length > 100 ? '...' : ''}"`);
@@ -586,7 +591,7 @@ export const delegateTool = (options = {}) => {
586
591
  * @returns {Object} Configured analyze_all tool
587
592
  */
588
593
  export const analyzeAllTool = (options = {}) => {
589
- const { sessionId, debug = false, delegationManager = null } = options;
594
+ const { sessionId, debug = false, delegationManager = null, workspaceRoot } = options;
590
595
 
591
596
  return tool({
592
597
  name: 'analyze_all',
@@ -609,12 +614,17 @@ export const analyzeAllTool = (options = {}) => {
609
614
  console.error(`[analyze_all] Path: ${searchPath}`);
610
615
  }
611
616
 
617
+ // Use workspaceRoot (computed common prefix) for consistent path handling
618
+ // Priority: workspaceRoot > cwd > allowedFolders[0] (consistent with bashTool)
619
+ const effectiveWorkspaceRoot = workspaceRoot || options.cwd || (options.allowedFolders && options.allowedFolders[0]);
620
+
612
621
  const result = await analyzeAll({
613
622
  question,
614
623
  path: searchPath,
615
624
  sessionId,
616
625
  debug,
617
626
  cwd: options.cwd,
627
+ workspaceRoot: effectiveWorkspaceRoot,
618
628
  allowedFolders: options.allowedFolders,
619
629
  provider: options.provider,
620
630
  model: options.model,
@@ -4,9 +4,48 @@
4
4
  */
5
5
 
6
6
  import path from 'path';
7
- import { promises as fs } from 'fs';
7
+ import { promises as fs, realpathSync } from 'fs';
8
8
  import { PathError } from './error-types.js';
9
9
 
10
+ /**
11
+ * Safely resolve symlinks for a path.
12
+ * Returns the real path if it exists, otherwise finds the nearest existing
13
+ * ancestor directory and resolves it, then appends the remaining path components.
14
+ * This is important for security to prevent symlink bypass attacks.
15
+ *
16
+ * @param {string} inputPath - Path to resolve
17
+ * @returns {string} Resolved real path or best-effort resolved path
18
+ */
19
+ export function safeRealpath(inputPath) {
20
+ try {
21
+ return realpathSync(inputPath);
22
+ } catch (error) {
23
+ // If path doesn't exist, find the nearest existing ancestor
24
+ // and resolve it, then append the remaining components.
25
+ // This handles cases like non-existent nested paths where an ancestor
26
+ // may be a symlink (e.g., /var -> /private/var on macOS)
27
+ const normalized = path.normalize(inputPath);
28
+ const parts = normalized.split(path.sep);
29
+
30
+ // Try progressively shorter paths until we find one that exists
31
+ for (let i = parts.length - 1; i >= 0; i--) {
32
+ const ancestorPath = parts.slice(0, i).join(path.sep) || path.sep;
33
+ try {
34
+ const resolvedAncestor = realpathSync(ancestorPath);
35
+ // Found an existing ancestor - append remaining components
36
+ const remainingParts = parts.slice(i);
37
+ return path.join(resolvedAncestor, ...remainingParts);
38
+ } catch (ancestorError) {
39
+ // This ancestor doesn't exist either, try the next one up
40
+ continue;
41
+ }
42
+ }
43
+
44
+ // No existing ancestor found, return normalized path
45
+ return normalized;
46
+ }
47
+ }
48
+
10
49
  /**
11
50
  * Validates and normalizes a path to be used as working directory (cwd).
12
51
  *
@@ -74,3 +113,111 @@ export function normalizePath(inputPath, defaultPath = process.cwd()) {
74
113
  const targetPath = inputPath || defaultPath;
75
114
  return path.normalize(path.resolve(targetPath));
76
115
  }
116
+
117
+ /**
118
+ * Compute the common prefix (workspace root) from an array of folder paths.
119
+ * This is useful for finding a single workspace root from multiple allowed folders.
120
+ *
121
+ * IMPORTANT: This function returns a value for DISPLAY and CWD purposes only.
122
+ * It is NOT a security boundary. All security checks should be performed against
123
+ * the original allowedFolders array, not against workspaceRoot.
124
+ *
125
+ * When no common prefix exists (e.g., unrelated paths), returns the first folder.
126
+ * This is intentional - the caller should use allowedFolders for security validation.
127
+ *
128
+ * Examples:
129
+ * - ['/tmp/ws/tyk', '/tmp/ws/tyk-docs'] -> '/tmp/ws'
130
+ * - ['/tmp/ws/tyk'] -> '/tmp/ws/tyk'
131
+ * - ['/a/b', '/c/d'] -> '/a/b' (no common prefix, returns first folder for cwd)
132
+ * - ['C:\\Users\\ws\\tyk', 'C:\\Users\\ws\\docs'] -> 'C:\\Users\\ws' (Windows)
133
+ *
134
+ * @param {string[]} folders - Array of absolute folder paths
135
+ * @returns {string} Common prefix path (for display/cwd, NOT security boundary)
136
+ */
137
+ export function getCommonPrefix(folders) {
138
+ if (!folders || folders.length === 0) {
139
+ return process.cwd();
140
+ }
141
+
142
+ if (folders.length === 1) {
143
+ // Resolve symlinks for security
144
+ return safeRealpath(folders[0]);
145
+ }
146
+
147
+ // Resolve symlinks and normalize all paths to handle mixed separators
148
+ // This prevents symlink bypass attacks where a symlink could point outside the workspace
149
+ const normalized = folders.map(f => safeRealpath(f));
150
+
151
+ // Split into segments
152
+ const segments = normalized.map(f => f.split(path.sep));
153
+
154
+ // Find minimum length
155
+ const minLen = Math.min(...segments.map(s => s.length));
156
+
157
+ // Find common prefix segments
158
+ const commonSegments = [];
159
+ for (let i = 0; i < minLen; i++) {
160
+ const segment = segments[0][i];
161
+ if (segments.every(s => s[i] === segment)) {
162
+ commonSegments.push(segment);
163
+ } else {
164
+ break;
165
+ }
166
+ }
167
+
168
+ // Handle edge cases
169
+ if (commonSegments.length === 0) {
170
+ // No common prefix at all, return first folder
171
+ return normalized[0];
172
+ }
173
+
174
+ // Handle Windows drive letters (e.g., 'C:')
175
+ // If only the drive letter is common, return first folder for more useful context
176
+ if (commonSegments.length === 1 && /^[a-zA-Z]:$/.test(commonSegments[0])) {
177
+ return normalized[0];
178
+ }
179
+
180
+ // Handle Unix root (empty string from split)
181
+ // If only the root '/' is common, return first folder for more useful context
182
+ if (commonSegments.length === 1 && commonSegments[0] === '') {
183
+ return normalized[0];
184
+ }
185
+
186
+ return commonSegments.join(path.sep);
187
+ }
188
+
189
+ /**
190
+ * Convert an absolute path to a relative path from the workspace root.
191
+ * Returns the original path if it cannot be made relative (outside workspace).
192
+ *
193
+ * @param {string} absolutePath - Absolute path to convert
194
+ * @param {string} workspaceRoot - Workspace root to compute relative path from
195
+ * @returns {string} Relative path or original if outside workspace
196
+ */
197
+ export function toRelativePath(absolutePath, workspaceRoot) {
198
+ if (!absolutePath || !workspaceRoot) {
199
+ return absolutePath;
200
+ }
201
+
202
+ // Resolve symlinks for security to prevent bypass attacks
203
+ // Use safeRealpath which falls back to normalized path if resolution fails
204
+ let normalized = safeRealpath(absolutePath);
205
+ let normalizedRoot = safeRealpath(workspaceRoot);
206
+
207
+ // Remove trailing separators (path.normalize doesn't always do this)
208
+ while (normalizedRoot.length > 1 && normalizedRoot.endsWith(path.sep)) {
209
+ normalizedRoot = normalizedRoot.slice(0, -1);
210
+ }
211
+
212
+ // Check if path is within workspace (exact match or starts with root + separator)
213
+ if (normalized === normalizedRoot) {
214
+ return '.';
215
+ }
216
+
217
+ if (normalized.startsWith(normalizedRoot + path.sep)) {
218
+ return path.relative(normalizedRoot, normalized);
219
+ }
220
+
221
+ // Path is outside workspace, return as-is
222
+ return absolutePath;
223
+ }