@probelabs/probe 0.6.0-rc225 → 0.6.0-rc227

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 (30) hide show
  1. package/bin/binaries/probe-v0.6.0-rc227-aarch64-apple-darwin.tar.gz +0 -0
  2. package/bin/binaries/probe-v0.6.0-rc227-aarch64-unknown-linux-musl.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc227-x86_64-apple-darwin.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc227-x86_64-pc-windows-msvc.zip +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc227-x86_64-unknown-linux-musl.tar.gz +0 -0
  6. package/build/agent/ProbeAgent.d.ts +24 -0
  7. package/build/agent/ProbeAgent.js +310 -141
  8. package/build/agent/engines/enhanced-claude-code.js +72 -3
  9. package/build/agent/index.js +386 -129
  10. package/build/tools/analyzeAll.js +6 -1
  11. package/build/tools/bash.js +18 -3
  12. package/build/tools/edit.js +19 -10
  13. package/build/tools/vercel.js +17 -7
  14. package/build/utils/path-validation.js +148 -1
  15. package/cjs/agent/ProbeAgent.cjs +683 -389
  16. package/cjs/index.cjs +680 -389
  17. package/package.json +1 -1
  18. package/src/agent/ProbeAgent.d.ts +24 -0
  19. package/src/agent/ProbeAgent.js +310 -141
  20. package/src/agent/engines/enhanced-claude-code.js +72 -3
  21. package/src/tools/analyzeAll.js +6 -1
  22. package/src/tools/bash.js +18 -3
  23. package/src/tools/edit.js +19 -10
  24. package/src/tools/vercel.js +17 -7
  25. package/src/utils/path-validation.js +148 -1
  26. package/bin/binaries/probe-v0.6.0-rc225-aarch64-apple-darwin.tar.gz +0 -0
  27. package/bin/binaries/probe-v0.6.0-rc225-aarch64-unknown-linux-musl.tar.gz +0 -0
  28. package/bin/binaries/probe-v0.6.0-rc225-x86_64-apple-darwin.tar.gz +0 -0
  29. package/bin/binaries/probe-v0.6.0-rc225-x86_64-pc-windows-msvc.zip +0 -0
  30. package/bin/binaries/probe-v0.6.0-rc225-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -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
+ }