@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.
- package/bin/binaries/probe-v0.6.0-rc227-aarch64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc227-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc227-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc227-x86_64-pc-windows-msvc.zip +0 -0
- package/bin/binaries/probe-v0.6.0-rc227-x86_64-unknown-linux-musl.tar.gz +0 -0
- package/build/agent/ProbeAgent.d.ts +24 -0
- package/build/agent/ProbeAgent.js +310 -141
- package/build/agent/engines/enhanced-claude-code.js +72 -3
- package/build/agent/index.js +386 -129
- package/build/tools/analyzeAll.js +6 -1
- package/build/tools/bash.js +18 -3
- package/build/tools/edit.js +19 -10
- package/build/tools/vercel.js +17 -7
- package/build/utils/path-validation.js +148 -1
- package/cjs/agent/ProbeAgent.cjs +683 -389
- package/cjs/index.cjs +680 -389
- package/package.json +1 -1
- package/src/agent/ProbeAgent.d.ts +24 -0
- package/src/agent/ProbeAgent.js +310 -141
- package/src/agent/engines/enhanced-claude-code.js +72 -3
- package/src/tools/analyzeAll.js +6 -1
- package/src/tools/bash.js +18 -3
- package/src/tools/edit.js +19 -10
- package/src/tools/vercel.js +17 -7
- package/src/utils/path-validation.js +148 -1
- package/bin/binaries/probe-v0.6.0-rc225-aarch64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc225-aarch64-unknown-linux-musl.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc225-x86_64-apple-darwin.tar.gz +0 -0
- package/bin/binaries/probe-v0.6.0-rc225-x86_64-pc-windows-msvc.zip +0 -0
- 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:
|
|
538
|
+
path: effectiveWorkspaceRoot,
|
|
534
539
|
allowedFolders,
|
|
535
540
|
provider,
|
|
536
541
|
model,
|
package/build/tools/bash.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/build/tools/edit.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/build/tools/vercel.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
533
|
-
// for subagent operations. Parent navigation context
|
|
534
|
-
|
|
535
|
-
|
|
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
|
+
}
|