tree-sitter-analyzer 0.9.9__py3-none-any.whl → 1.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of tree-sitter-analyzer might be problematic. Click here for more details.
- tree_sitter_analyzer/__init__.py +1 -1
- tree_sitter_analyzer/api.py +11 -2
- tree_sitter_analyzer/cli/commands/base_command.py +2 -2
- tree_sitter_analyzer/cli/commands/partial_read_command.py +2 -2
- tree_sitter_analyzer/cli/info_commands.py +7 -4
- tree_sitter_analyzer/cli_main.py +1 -2
- tree_sitter_analyzer/core/analysis_engine.py +2 -2
- tree_sitter_analyzer/core/query_service.py +162 -162
- tree_sitter_analyzer/file_handler.py +6 -4
- tree_sitter_analyzer/formatters/base_formatter.py +6 -4
- tree_sitter_analyzer/mcp/__init__.py +1 -1
- tree_sitter_analyzer/mcp/resources/__init__.py +1 -1
- tree_sitter_analyzer/mcp/resources/project_stats_resource.py +16 -5
- tree_sitter_analyzer/mcp/server.py +16 -11
- tree_sitter_analyzer/mcp/tools/__init__.py +1 -1
- tree_sitter_analyzer/mcp/utils/__init__.py +2 -2
- tree_sitter_analyzer/mcp/utils/error_handler.py +569 -569
- tree_sitter_analyzer/mcp/utils/path_resolver.py +240 -20
- tree_sitter_analyzer/output_manager.py +7 -3
- tree_sitter_analyzer/project_detector.py +27 -27
- tree_sitter_analyzer/security/boundary_manager.py +37 -28
- tree_sitter_analyzer/security/validator.py +23 -12
- tree_sitter_analyzer/table_formatter.py +6 -4
- tree_sitter_analyzer/utils.py +1 -2
- {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/METADATA +38 -12
- {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/RECORD +28 -28
- {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/WHEEL +0 -0
- {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -9,10 +9,91 @@ operating systems.
|
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
|
+
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _normalize_path_cross_platform(path_str: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Normalize path for cross-platform compatibility.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path_str: Input path string
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Normalized path string
|
|
26
|
+
"""
|
|
27
|
+
if not path_str:
|
|
28
|
+
return path_str
|
|
29
|
+
|
|
30
|
+
# Handle macOS path normalization issues
|
|
31
|
+
if os.name == "posix":
|
|
32
|
+
# Handle /System/Volumes/Data prefix on macOS
|
|
33
|
+
if path_str.startswith("/System/Volumes/Data/"):
|
|
34
|
+
# Remove the /System/Volumes/Data prefix
|
|
35
|
+
normalized = path_str[len("/System/Volumes/Data") :]
|
|
36
|
+
return normalized
|
|
37
|
+
|
|
38
|
+
# Handle /private/var vs /var symlink difference on macOS
|
|
39
|
+
if path_str.startswith("/private/var/"):
|
|
40
|
+
# Always normalize to /var form on macOS for consistency
|
|
41
|
+
var_path = path_str.replace("/private/var/", "/var/", 1)
|
|
42
|
+
return var_path
|
|
43
|
+
elif path_str.startswith("/var/"):
|
|
44
|
+
# Keep /var form as is
|
|
45
|
+
return path_str
|
|
46
|
+
|
|
47
|
+
# Handle Windows short path names (8.3 format)
|
|
48
|
+
if os.name == "nt" and path_str:
|
|
49
|
+
try:
|
|
50
|
+
# Try to get the long path name on Windows
|
|
51
|
+
import ctypes
|
|
52
|
+
from ctypes import wintypes
|
|
53
|
+
|
|
54
|
+
# GetLongPathNameW function
|
|
55
|
+
_GetLongPathNameW = ctypes.windll.kernel32.GetLongPathNameW
|
|
56
|
+
_GetLongPathNameW.argtypes = [
|
|
57
|
+
wintypes.LPCWSTR,
|
|
58
|
+
wintypes.LPWSTR,
|
|
59
|
+
wintypes.DWORD,
|
|
60
|
+
]
|
|
61
|
+
_GetLongPathNameW.restype = wintypes.DWORD
|
|
62
|
+
|
|
63
|
+
# Buffer for the long path
|
|
64
|
+
buffer_size = 1000
|
|
65
|
+
buffer = ctypes.create_unicode_buffer(buffer_size)
|
|
66
|
+
|
|
67
|
+
# Get the long path name
|
|
68
|
+
result = _GetLongPathNameW(path_str, buffer, buffer_size)
|
|
69
|
+
if result > 0 and result < buffer_size:
|
|
70
|
+
long_path = buffer.value
|
|
71
|
+
if long_path and long_path != path_str:
|
|
72
|
+
return long_path
|
|
73
|
+
except (ImportError, AttributeError, OSError):
|
|
74
|
+
# If Windows API calls fail, continue with original path
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return path_str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_windows_absolute_path(path_str: str) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Check if a path is a Windows-style absolute path.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path_str: Path string to check
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if it's a Windows absolute path (e.g., C:\\path or C:/path)
|
|
89
|
+
"""
|
|
90
|
+
if not path_str or len(path_str) < 3:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# Check for drive letter pattern: X:\ or X:/
|
|
94
|
+
return path_str[1] == ":" and path_str[0].isalpha() and path_str[2] in ("\\", "/")
|
|
95
|
+
|
|
96
|
+
|
|
16
97
|
class PathResolver:
|
|
17
98
|
"""
|
|
18
99
|
Utility class for resolving file paths in MCP tools.
|
|
@@ -28,10 +109,20 @@ class PathResolver:
|
|
|
28
109
|
Args:
|
|
29
110
|
project_root: Optional project root directory for resolving relative paths
|
|
30
111
|
"""
|
|
31
|
-
self.project_root =
|
|
112
|
+
self.project_root = None
|
|
113
|
+
self._cache = {} # Simple cache for resolved paths
|
|
114
|
+
self._cache_size_limit = 100 # Limit cache size to prevent memory issues
|
|
115
|
+
|
|
32
116
|
if project_root:
|
|
33
|
-
#
|
|
34
|
-
|
|
117
|
+
# Use pathlib for consistent path handling, but preserve relative paths for compatibility
|
|
118
|
+
path_obj = Path(project_root)
|
|
119
|
+
if path_obj.is_absolute():
|
|
120
|
+
resolved_root = str(path_obj.resolve())
|
|
121
|
+
# Apply cross-platform normalization
|
|
122
|
+
self.project_root = _normalize_path_cross_platform(resolved_root)
|
|
123
|
+
else:
|
|
124
|
+
# For relative paths, normalize but don't resolve to absolute
|
|
125
|
+
self.project_root = str(path_obj)
|
|
35
126
|
logger.debug(
|
|
36
127
|
f"PathResolver initialized with project root: {self.project_root}"
|
|
37
128
|
)
|
|
@@ -48,34 +139,126 @@ class PathResolver:
|
|
|
48
139
|
|
|
49
140
|
Raises:
|
|
50
141
|
ValueError: If file_path is empty or None
|
|
142
|
+
TypeError: If file_path is not a string
|
|
51
143
|
"""
|
|
52
144
|
if not file_path:
|
|
53
145
|
raise ValueError("file_path cannot be empty or None")
|
|
54
146
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
147
|
+
if not isinstance(file_path, str):
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"file_path must be a string, got {type(file_path).__name__}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Check cache first
|
|
153
|
+
if file_path in self._cache:
|
|
154
|
+
logger.debug(f"Cache hit for path: {file_path}")
|
|
155
|
+
return self._cache[file_path]
|
|
156
|
+
|
|
157
|
+
# Normalize path separators first
|
|
158
|
+
normalized_input = file_path.replace("\\", "/")
|
|
159
|
+
|
|
160
|
+
# Special handling for Windows absolute paths on non-Windows systems
|
|
161
|
+
if _is_windows_absolute_path(file_path) and os.name != "nt":
|
|
162
|
+
# On non-Windows systems, Windows absolute paths should be treated as-is
|
|
163
|
+
# Don't try to resolve them relative to project root
|
|
164
|
+
logger.debug(f"Windows absolute path on non-Windows system: {file_path}")
|
|
165
|
+
self._add_to_cache(file_path, file_path)
|
|
166
|
+
return file_path
|
|
167
|
+
|
|
168
|
+
path_obj = Path(normalized_input)
|
|
169
|
+
|
|
170
|
+
# Handle Unix-style absolute paths on Windows (starting with /) FIRST
|
|
171
|
+
# This must come before the is_absolute() check because Unix paths aren't
|
|
172
|
+
# considered absolute on Windows by pathlib
|
|
173
|
+
if (
|
|
174
|
+
normalized_input.startswith("/") and os.name == "nt"
|
|
175
|
+
): # Check if we're on Windows
|
|
176
|
+
# On Windows, convert Unix-style absolute paths to Windows format
|
|
177
|
+
# by prepending the current drive with proper separator
|
|
178
|
+
current_drive = Path.cwd().drive
|
|
179
|
+
if current_drive:
|
|
180
|
+
# Remove leading slash and join with current drive
|
|
181
|
+
unix_path_without_slash = normalized_input[1:]
|
|
182
|
+
# Ensure proper Windows path format with backslash after drive
|
|
183
|
+
resolved_path = str(
|
|
184
|
+
Path(current_drive + "\\") / unix_path_without_slash
|
|
185
|
+
)
|
|
186
|
+
logger.debug(
|
|
187
|
+
f"Converted Unix absolute path: {file_path} -> {resolved_path}"
|
|
188
|
+
)
|
|
189
|
+
# Apply cross-platform normalization
|
|
190
|
+
resolved_path = _normalize_path_cross_platform(resolved_path)
|
|
191
|
+
self._add_to_cache(file_path, resolved_path)
|
|
192
|
+
return resolved_path
|
|
193
|
+
# If no drive available, continue with normal processing
|
|
194
|
+
|
|
195
|
+
# Check if path is absolute
|
|
196
|
+
if path_obj.is_absolute():
|
|
197
|
+
resolved_path = str(path_obj.resolve())
|
|
58
198
|
logger.debug(f"Path already absolute: {file_path} -> {resolved_path}")
|
|
199
|
+
# Apply cross-platform normalization
|
|
200
|
+
resolved_path = _normalize_path_cross_platform(resolved_path)
|
|
201
|
+
self._add_to_cache(file_path, resolved_path)
|
|
59
202
|
return resolved_path
|
|
60
203
|
|
|
61
204
|
# If we have a project root, resolve relative to it
|
|
62
205
|
if self.project_root:
|
|
63
|
-
resolved_path =
|
|
64
|
-
# Normalize path separators for cross-platform compatibility
|
|
65
|
-
resolved_path = os.path.normpath(resolved_path)
|
|
206
|
+
resolved_path = str((Path(self.project_root) / normalized_input).resolve())
|
|
66
207
|
logger.debug(
|
|
67
208
|
f"Resolved relative path '{file_path}' to '{resolved_path}' using project root"
|
|
68
209
|
)
|
|
210
|
+
# Apply cross-platform normalization
|
|
211
|
+
resolved_path = _normalize_path_cross_platform(resolved_path)
|
|
212
|
+
self._add_to_cache(file_path, resolved_path)
|
|
69
213
|
return resolved_path
|
|
70
214
|
|
|
71
215
|
# Fallback: try to resolve relative to current working directory
|
|
72
|
-
resolved_path =
|
|
73
|
-
resolved_path = os.path.normpath(resolved_path)
|
|
216
|
+
resolved_path = str(Path(normalized_input).resolve())
|
|
74
217
|
logger.debug(
|
|
75
218
|
f"Resolved relative path '{file_path}' to '{resolved_path}' using current working directory"
|
|
76
219
|
)
|
|
220
|
+
|
|
221
|
+
# Apply cross-platform normalization
|
|
222
|
+
resolved_path = _normalize_path_cross_platform(resolved_path)
|
|
223
|
+
|
|
224
|
+
# Cache the result
|
|
225
|
+
self._add_to_cache(file_path, resolved_path)
|
|
226
|
+
|
|
77
227
|
return resolved_path
|
|
78
228
|
|
|
229
|
+
def _add_to_cache(self, file_path: str, resolved_path: str) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Add a resolved path to the cache.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
file_path: Original file path
|
|
235
|
+
resolved_path: Resolved absolute path
|
|
236
|
+
"""
|
|
237
|
+
# Limit cache size to prevent memory issues
|
|
238
|
+
if len(self._cache) >= self._cache_size_limit:
|
|
239
|
+
# Remove oldest entries (simple FIFO)
|
|
240
|
+
oldest_key = next(iter(self._cache))
|
|
241
|
+
del self._cache[oldest_key]
|
|
242
|
+
logger.debug(f"Cache full, removed oldest entry: {oldest_key}")
|
|
243
|
+
|
|
244
|
+
self._cache[file_path] = resolved_path
|
|
245
|
+
logger.debug(f"Cached path resolution: {file_path} -> {resolved_path}")
|
|
246
|
+
|
|
247
|
+
def clear_cache(self) -> None:
|
|
248
|
+
"""Clear the path resolution cache."""
|
|
249
|
+
cache_size = len(self._cache)
|
|
250
|
+
self._cache.clear()
|
|
251
|
+
logger.info(f"Cleared path resolution cache ({cache_size} entries)")
|
|
252
|
+
|
|
253
|
+
def get_cache_stats(self) -> dict[str, int]:
|
|
254
|
+
"""
|
|
255
|
+
Get cache statistics.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Dictionary with cache statistics
|
|
259
|
+
"""
|
|
260
|
+
return {"size": len(self._cache), "limit": self._cache_size_limit}
|
|
261
|
+
|
|
79
262
|
def is_relative(self, file_path: str) -> bool:
|
|
80
263
|
"""
|
|
81
264
|
Check if a file path is relative.
|
|
@@ -86,7 +269,7 @@ class PathResolver:
|
|
|
86
269
|
Returns:
|
|
87
270
|
True if the path is relative, False if absolute
|
|
88
271
|
"""
|
|
89
|
-
return not
|
|
272
|
+
return not Path(file_path).is_absolute()
|
|
90
273
|
|
|
91
274
|
def get_relative_path(self, absolute_path: str) -> str:
|
|
92
275
|
"""
|
|
@@ -101,15 +284,32 @@ class PathResolver:
|
|
|
101
284
|
Raises:
|
|
102
285
|
ValueError: If absolute_path is not actually absolute
|
|
103
286
|
"""
|
|
104
|
-
|
|
287
|
+
abs_path = Path(absolute_path)
|
|
288
|
+
if not abs_path.is_absolute():
|
|
105
289
|
raise ValueError(f"Path is not absolute: {absolute_path}")
|
|
106
290
|
|
|
107
291
|
if not self.project_root:
|
|
108
292
|
return absolute_path
|
|
109
293
|
|
|
110
294
|
try:
|
|
111
|
-
# Get relative path from project root
|
|
112
|
-
|
|
295
|
+
# Get relative path from project root using pathlib
|
|
296
|
+
project_path = Path(self.project_root)
|
|
297
|
+
|
|
298
|
+
# Normalize both paths for consistent comparison
|
|
299
|
+
normalized_abs_path = _normalize_path_cross_platform(
|
|
300
|
+
str(abs_path.resolve())
|
|
301
|
+
)
|
|
302
|
+
normalized_project_root = _normalize_path_cross_platform(
|
|
303
|
+
str(project_path.resolve())
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Convert back to Path objects for relative_to calculation
|
|
307
|
+
normalized_abs_path_obj = Path(normalized_abs_path)
|
|
308
|
+
normalized_project_root_obj = Path(normalized_project_root)
|
|
309
|
+
|
|
310
|
+
relative_path = str(
|
|
311
|
+
normalized_abs_path_obj.relative_to(normalized_project_root_obj)
|
|
312
|
+
)
|
|
113
313
|
logger.debug(
|
|
114
314
|
f"Converted absolute path '{absolute_path}' to relative path '{relative_path}'"
|
|
115
315
|
)
|
|
@@ -133,19 +333,31 @@ class PathResolver:
|
|
|
133
333
|
"""
|
|
134
334
|
try:
|
|
135
335
|
resolved_path = self.resolve(file_path)
|
|
336
|
+
resolved_path_obj = Path(resolved_path)
|
|
136
337
|
|
|
137
|
-
|
|
138
|
-
if not os.path.exists(resolved_path):
|
|
338
|
+
if not resolved_path_obj.exists():
|
|
139
339
|
return False, f"File does not exist: {resolved_path}"
|
|
140
340
|
|
|
141
341
|
# Check if it's a file (not directory)
|
|
142
|
-
if not
|
|
342
|
+
if not resolved_path_obj.is_file():
|
|
143
343
|
return False, f"Path is not a file: {resolved_path}"
|
|
144
344
|
|
|
345
|
+
# Check if it's a symlink (reject symlinks for security)
|
|
346
|
+
try:
|
|
347
|
+
if resolved_path_obj.is_symlink():
|
|
348
|
+
return False, f"Path is a symlink: {resolved_path}"
|
|
349
|
+
except (OSError, AttributeError):
|
|
350
|
+
# is_symlink() might not be available on all platforms
|
|
351
|
+
# or might fail due to permissions, skip this check
|
|
352
|
+
pass
|
|
353
|
+
|
|
145
354
|
# Check if it's within project root (if we have one)
|
|
146
355
|
if self.project_root:
|
|
147
356
|
try:
|
|
148
|
-
|
|
357
|
+
project_path = Path(self.project_root).resolve()
|
|
358
|
+
resolved_abs_path = resolved_path_obj.resolve()
|
|
359
|
+
# Check if the resolved path is within the project root
|
|
360
|
+
resolved_abs_path.relative_to(project_path)
|
|
149
361
|
except ValueError:
|
|
150
362
|
return False, f"File path is outside project root: {resolved_path}"
|
|
151
363
|
|
|
@@ -171,7 +383,15 @@ class PathResolver:
|
|
|
171
383
|
project_root: New project root directory
|
|
172
384
|
"""
|
|
173
385
|
if project_root:
|
|
174
|
-
|
|
386
|
+
# Use pathlib for consistent path handling, but preserve relative paths for compatibility
|
|
387
|
+
path_obj = Path(project_root)
|
|
388
|
+
if path_obj.is_absolute():
|
|
389
|
+
resolved_root = str(path_obj.resolve())
|
|
390
|
+
# Apply cross-platform normalization
|
|
391
|
+
self.project_root = _normalize_path_cross_platform(resolved_root)
|
|
392
|
+
else:
|
|
393
|
+
# For relative paths, normalize but don't resolve to absolute
|
|
394
|
+
self.project_root = str(path_obj)
|
|
175
395
|
logger.info(f"Project root updated to: {self.project_root}")
|
|
176
396
|
else:
|
|
177
397
|
self.project_root = None
|
|
@@ -120,9 +120,13 @@ class OutputManager:
|
|
|
120
120
|
"""Output supported extensions"""
|
|
121
121
|
if not self.quiet:
|
|
122
122
|
print("Supported file extensions:")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
# Use more efficient chunking
|
|
124
|
+
from itertools import islice
|
|
125
|
+
|
|
126
|
+
chunk_size = 10
|
|
127
|
+
for i in range(0, len(extensions), chunk_size):
|
|
128
|
+
chunk = list(islice(extensions, i, i + chunk_size))
|
|
129
|
+
print(f" {' '.join(chunk)}")
|
|
126
130
|
print(f"Total {len(extensions)} extensions supported")
|
|
127
131
|
|
|
128
132
|
def output_json(self, data: Any) -> None:
|
|
@@ -6,7 +6,7 @@ Intelligent detection of project root directories based on common project marker
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
-
import
|
|
9
|
+
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
@@ -93,13 +93,13 @@ class ProjectRootDetector:
|
|
|
93
93
|
|
|
94
94
|
try:
|
|
95
95
|
# Convert to absolute path and get directory
|
|
96
|
-
abs_path =
|
|
97
|
-
if
|
|
98
|
-
start_dir =
|
|
96
|
+
abs_path = Path(file_path).resolve()
|
|
97
|
+
if abs_path.is_file():
|
|
98
|
+
start_dir = abs_path.parent
|
|
99
99
|
else:
|
|
100
100
|
start_dir = abs_path
|
|
101
101
|
|
|
102
|
-
return self._traverse_upward(start_dir)
|
|
102
|
+
return self._traverse_upward(str(start_dir))
|
|
103
103
|
|
|
104
104
|
except Exception as e:
|
|
105
105
|
logger.warning(f"Error detecting project root from {file_path}: {e}")
|
|
@@ -113,7 +113,7 @@ class ProjectRootDetector:
|
|
|
113
113
|
Project root directory path, or None if not detected
|
|
114
114
|
"""
|
|
115
115
|
try:
|
|
116
|
-
return self._traverse_upward(
|
|
116
|
+
return self._traverse_upward(str(Path.cwd()))
|
|
117
117
|
except Exception as e:
|
|
118
118
|
logger.warning(f"Error detecting project root from cwd: {e}")
|
|
119
119
|
return None
|
|
@@ -128,7 +128,7 @@ class ProjectRootDetector:
|
|
|
128
128
|
Returns:
|
|
129
129
|
Project root directory path, or None if not found
|
|
130
130
|
"""
|
|
131
|
-
current_dir =
|
|
131
|
+
current_dir = str(Path(start_dir).resolve())
|
|
132
132
|
candidates = []
|
|
133
133
|
|
|
134
134
|
for _depth in range(self.max_depth):
|
|
@@ -159,10 +159,11 @@ class ProjectRootDetector:
|
|
|
159
159
|
return current_dir
|
|
160
160
|
|
|
161
161
|
# Move up one directory
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
current_path = Path(current_dir)
|
|
163
|
+
parent_path = current_path.parent
|
|
164
|
+
if parent_path == current_path: # Reached filesystem root
|
|
164
165
|
break
|
|
165
|
-
current_dir =
|
|
166
|
+
current_dir = str(parent_path)
|
|
166
167
|
|
|
167
168
|
# Return the best candidate if any found
|
|
168
169
|
if candidates:
|
|
@@ -190,19 +191,16 @@ class ProjectRootDetector:
|
|
|
190
191
|
found_markers = []
|
|
191
192
|
|
|
192
193
|
try:
|
|
193
|
-
|
|
194
|
+
dir_path = Path(directory)
|
|
194
195
|
|
|
195
196
|
for marker in PROJECT_MARKERS:
|
|
196
197
|
if "*" in marker:
|
|
197
|
-
# Handle glob patterns
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
pattern = os.path.join(directory, marker)
|
|
201
|
-
if glob.glob(pattern):
|
|
198
|
+
# Handle glob patterns using pathlib
|
|
199
|
+
if list(dir_path.glob(marker)):
|
|
202
200
|
found_markers.append(marker)
|
|
203
201
|
else:
|
|
204
202
|
# Handle exact matches
|
|
205
|
-
if
|
|
203
|
+
if (dir_path / marker).exists():
|
|
206
204
|
found_markers.append(marker)
|
|
207
205
|
|
|
208
206
|
except (OSError, PermissionError) as e:
|
|
@@ -258,15 +256,16 @@ class ProjectRootDetector:
|
|
|
258
256
|
Fallback directory (file's directory or cwd)
|
|
259
257
|
"""
|
|
260
258
|
try:
|
|
261
|
-
if file_path
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
259
|
+
if file_path:
|
|
260
|
+
path = Path(file_path)
|
|
261
|
+
if path.exists():
|
|
262
|
+
if path.is_file():
|
|
263
|
+
return str(path.resolve().parent)
|
|
264
|
+
else:
|
|
265
|
+
return str(path.resolve())
|
|
266
|
+
return str(Path.cwd())
|
|
268
267
|
except Exception:
|
|
269
|
-
return
|
|
268
|
+
return str(Path.cwd())
|
|
270
269
|
|
|
271
270
|
|
|
272
271
|
def detect_project_root(
|
|
@@ -292,9 +291,10 @@ def detect_project_root(
|
|
|
292
291
|
|
|
293
292
|
# Priority 1: Explicit root
|
|
294
293
|
if explicit_root:
|
|
295
|
-
|
|
294
|
+
explicit_path = Path(explicit_root)
|
|
295
|
+
if explicit_path.exists() and explicit_path.is_dir():
|
|
296
296
|
logger.debug(f"Using explicit project root: {explicit_root}")
|
|
297
|
-
return
|
|
297
|
+
return str(explicit_path.resolve())
|
|
298
298
|
else:
|
|
299
299
|
logger.warning(f"Explicit project root does not exist: {explicit_root}")
|
|
300
300
|
|
|
@@ -6,7 +6,6 @@ Provides strict project boundary control to prevent access to files
|
|
|
6
6
|
outside the designated project directory.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import os
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
12
11
|
from ..exceptions import SecurityError
|
|
@@ -40,14 +39,15 @@ class ProjectBoundaryManager:
|
|
|
40
39
|
if not project_root:
|
|
41
40
|
raise SecurityError("Project root cannot be empty")
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
project_path = Path(project_root)
|
|
43
|
+
if not project_path.exists():
|
|
44
44
|
raise SecurityError(f"Project root does not exist: {project_root}")
|
|
45
45
|
|
|
46
|
-
if not
|
|
46
|
+
if not project_path.is_dir():
|
|
47
47
|
raise SecurityError(f"Project root is not a directory: {project_root}")
|
|
48
48
|
|
|
49
49
|
# Store real path to prevent symlink attacks
|
|
50
|
-
self.project_root =
|
|
50
|
+
self.project_root = str(project_path.resolve())
|
|
51
51
|
self.allowed_directories: set[str] = {self.project_root}
|
|
52
52
|
|
|
53
53
|
log_debug(f"ProjectBoundaryManager initialized with root: {self.project_root}")
|
|
@@ -65,13 +65,14 @@ class ProjectBoundaryManager:
|
|
|
65
65
|
if not directory:
|
|
66
66
|
raise SecurityError("Directory cannot be empty")
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
dir_path = Path(directory)
|
|
69
|
+
if not dir_path.exists():
|
|
69
70
|
raise SecurityError(f"Directory does not exist: {directory}")
|
|
70
71
|
|
|
71
|
-
if not
|
|
72
|
+
if not dir_path.is_dir():
|
|
72
73
|
raise SecurityError(f"Path is not a directory: {directory}")
|
|
73
74
|
|
|
74
|
-
real_dir =
|
|
75
|
+
real_dir = str(dir_path.resolve())
|
|
75
76
|
self.allowed_directories.add(real_dir)
|
|
76
77
|
|
|
77
78
|
log_info(f"Added allowed directory: {real_dir}")
|
|
@@ -92,16 +93,18 @@ class ProjectBoundaryManager:
|
|
|
92
93
|
return False
|
|
93
94
|
|
|
94
95
|
# Resolve real path to handle symlinks
|
|
95
|
-
real_path =
|
|
96
|
+
real_path = str(Path(file_path).resolve())
|
|
96
97
|
|
|
97
98
|
# Check against all allowed directories
|
|
98
99
|
for allowed_dir in self.allowed_directories:
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
):
|
|
100
|
+
# Use pathlib to check if path is within allowed directory
|
|
101
|
+
try:
|
|
102
|
+
Path(real_path).relative_to(Path(allowed_dir))
|
|
103
103
|
log_debug(f"File path within boundaries: {file_path}")
|
|
104
104
|
return True
|
|
105
|
+
except ValueError:
|
|
106
|
+
# Path is not within this allowed directory, continue checking
|
|
107
|
+
continue
|
|
105
108
|
|
|
106
109
|
log_warning(f"File path outside boundaries: {file_path} -> {real_path}")
|
|
107
110
|
return False
|
|
@@ -124,8 +127,14 @@ class ProjectBoundaryManager:
|
|
|
124
127
|
return None
|
|
125
128
|
|
|
126
129
|
try:
|
|
127
|
-
real_path =
|
|
128
|
-
|
|
130
|
+
real_path = Path(file_path).resolve()
|
|
131
|
+
try:
|
|
132
|
+
rel_path = real_path.relative_to(Path(self.project_root))
|
|
133
|
+
rel_path = str(rel_path)
|
|
134
|
+
except ValueError:
|
|
135
|
+
# Path is not relative to project root
|
|
136
|
+
log_warning(f"Path not relative to project root: {file_path}")
|
|
137
|
+
return None
|
|
129
138
|
|
|
130
139
|
# Ensure relative path doesn't start with ..
|
|
131
140
|
if rel_path.startswith(".."):
|
|
@@ -150,17 +159,18 @@ class ProjectBoundaryManager:
|
|
|
150
159
|
"""
|
|
151
160
|
try:
|
|
152
161
|
# Handle relative paths from project root
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
file_path_obj = Path(file_path)
|
|
163
|
+
if not file_path_obj.is_absolute():
|
|
164
|
+
full_path = Path(self.project_root) / file_path
|
|
155
165
|
else:
|
|
156
|
-
full_path =
|
|
166
|
+
full_path = file_path_obj
|
|
157
167
|
|
|
158
168
|
# Check boundaries
|
|
159
|
-
if not self.is_within_project(full_path):
|
|
169
|
+
if not self.is_within_project(str(full_path)):
|
|
160
170
|
return None
|
|
161
171
|
|
|
162
172
|
# Return real path
|
|
163
|
-
return
|
|
173
|
+
return str(full_path.resolve())
|
|
164
174
|
|
|
165
175
|
except Exception as e:
|
|
166
176
|
log_warning(f"Path validation error: {e}")
|
|
@@ -186,28 +196,27 @@ class ProjectBoundaryManager:
|
|
|
186
196
|
True if path is safe from symlink attacks
|
|
187
197
|
"""
|
|
188
198
|
try:
|
|
189
|
-
|
|
199
|
+
file_path_obj = Path(file_path)
|
|
200
|
+
if not file_path_obj.exists():
|
|
190
201
|
return True # Non-existent files are safe
|
|
191
202
|
|
|
192
203
|
# If the fully resolved path is within project boundaries, we treat it as safe.
|
|
193
204
|
# This makes the check tolerant to system-level symlinks like
|
|
194
205
|
# /var -> /private/var on macOS runners.
|
|
195
|
-
resolved =
|
|
206
|
+
resolved = str(file_path_obj.resolve())
|
|
196
207
|
if self.is_within_project(resolved):
|
|
197
208
|
return True
|
|
198
209
|
|
|
199
210
|
# Otherwise, inspect each path component symlink to ensure no hop jumps outside
|
|
200
211
|
# the allowed directories.
|
|
201
|
-
path_parts =
|
|
202
|
-
current_path =
|
|
212
|
+
path_parts = file_path_obj.parts
|
|
213
|
+
current_path = Path()
|
|
203
214
|
|
|
204
215
|
for part in path_parts:
|
|
205
|
-
current_path = (
|
|
206
|
-
os.path.join(current_path, part) if current_path else part
|
|
207
|
-
)
|
|
216
|
+
current_path = current_path / part if current_path.parts else Path(part)
|
|
208
217
|
|
|
209
|
-
if
|
|
210
|
-
target =
|
|
218
|
+
if current_path.is_symlink():
|
|
219
|
+
target = str(current_path.resolve())
|
|
211
220
|
if not self.is_within_project(target):
|
|
212
221
|
log_warning(
|
|
213
222
|
f"Unsafe symlink detected: {current_path} -> {target}"
|