contextual-engine 0.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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Path safety utilities for preventing traversal and injection attacks.
|
|
2
|
+
|
|
3
|
+
This module provides bulletproof path validation to prevent:
|
|
4
|
+
- Path traversal attacks (../../../etc/passwd)
|
|
5
|
+
- Symlink attacks (links outside project root)
|
|
6
|
+
- Windows reserved name attacks (CON, PRN, AUX, NUL)
|
|
7
|
+
- Excessive path depth attacks
|
|
8
|
+
- Invalid path characters
|
|
9
|
+
|
|
10
|
+
All file operations in Contextual must use safe_path() before accessing the filesystem.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from contextual.core.errors import ErrorCode, SecurityError, security_context
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Windows reserved device names that must be rejected
|
|
22
|
+
WINDOWS_RESERVED_NAMES = frozenset({
|
|
23
|
+
"CON", "PRN", "AUX", "NUL",
|
|
24
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
25
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_path(root: Path, candidate: Path | str, *, allow_create: bool = False) -> Path:
|
|
30
|
+
"""Validate and resolve a path safely within a root directory.
|
|
31
|
+
|
|
32
|
+
This is the primary path safety function. ALL file operations in Contextual
|
|
33
|
+
must call this before touching the filesystem.
|
|
34
|
+
|
|
35
|
+
Security guarantees:
|
|
36
|
+
1. Path is resolved to absolute form
|
|
37
|
+
2. Symlinks are resolved (no following links outside root)
|
|
38
|
+
3. Result is strictly within root directory
|
|
39
|
+
4. No path traversal sequences (../) can escape
|
|
40
|
+
5. No Windows reserved device names
|
|
41
|
+
6. Path depth is within limits
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
root: Root directory that must contain the result.
|
|
45
|
+
candidate: Path to validate (relative or absolute).
|
|
46
|
+
allow_create: If True, don't require path to exist (for file creation).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Validated absolute path guaranteed to be within root.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SecurityError: If path validation fails for any reason.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
>>> root = Path("/home/user/project")
|
|
56
|
+
>>> safe_path(root, "src/main.py") # OK
|
|
57
|
+
Path('/home/user/project/src/main.py')
|
|
58
|
+
|
|
59
|
+
>>> safe_path(root, "../../../etc/passwd") # BLOCKED
|
|
60
|
+
SecurityError: Path traversal attempt
|
|
61
|
+
|
|
62
|
+
>>> safe_path(root, "/tmp/evil.txt") # BLOCKED
|
|
63
|
+
SecurityError: Path outside root
|
|
64
|
+
"""
|
|
65
|
+
# Ensure root is absolute
|
|
66
|
+
if not root.is_absolute():
|
|
67
|
+
msg = "Root path must be absolute"
|
|
68
|
+
raise SecurityError(
|
|
69
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
70
|
+
message=msg,
|
|
71
|
+
context=security_context(path=str(root)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Convert candidate to Path if string
|
|
75
|
+
if isinstance(candidate, str):
|
|
76
|
+
candidate = Path(candidate)
|
|
77
|
+
|
|
78
|
+
# Resolve root (follows symlinks, gets absolute path)
|
|
79
|
+
try:
|
|
80
|
+
resolved_root = root.resolve(strict=True)
|
|
81
|
+
except (OSError, RuntimeError) as e:
|
|
82
|
+
# OSError: path doesn't exist or permission denied
|
|
83
|
+
# RuntimeError: symlink loop (Python <3.13)
|
|
84
|
+
msg = f"Failed to resolve root path: {e}"
|
|
85
|
+
raise SecurityError(
|
|
86
|
+
code=ErrorCode.SECURITY_PATH_SYMLINK_LOOP,
|
|
87
|
+
message=msg,
|
|
88
|
+
context=security_context(path=str(root)),
|
|
89
|
+
) from e
|
|
90
|
+
|
|
91
|
+
# Build full candidate path
|
|
92
|
+
if candidate.is_absolute():
|
|
93
|
+
full_path = candidate
|
|
94
|
+
else:
|
|
95
|
+
full_path = resolved_root / candidate
|
|
96
|
+
|
|
97
|
+
# Resolve candidate path
|
|
98
|
+
try:
|
|
99
|
+
if allow_create:
|
|
100
|
+
# For file creation, resolve parent and append filename
|
|
101
|
+
if full_path.parent.exists():
|
|
102
|
+
resolved_parent = full_path.parent.resolve(strict=True)
|
|
103
|
+
resolved_path = resolved_parent / full_path.name
|
|
104
|
+
else:
|
|
105
|
+
# Parent doesn't exist - resolve as much as possible
|
|
106
|
+
# Walk up until we find an existing parent
|
|
107
|
+
parts = list(full_path.parts)
|
|
108
|
+
for i in range(len(parts), 0, -1):
|
|
109
|
+
test_path = Path(*parts[:i])
|
|
110
|
+
if test_path.exists():
|
|
111
|
+
resolved_existing = test_path.resolve(strict=True)
|
|
112
|
+
remaining = Path(*parts[i:])
|
|
113
|
+
resolved_path = resolved_existing / remaining
|
|
114
|
+
break
|
|
115
|
+
else:
|
|
116
|
+
# No existing ancestor found
|
|
117
|
+
msg = "No existing ancestor directory found"
|
|
118
|
+
raise SecurityError(
|
|
119
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
120
|
+
message=msg,
|
|
121
|
+
context=security_context(path=str(full_path), root=str(resolved_root)),
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
# For reading, path must exist
|
|
125
|
+
resolved_path = full_path.resolve(strict=True)
|
|
126
|
+
except (OSError, RuntimeError) as e:
|
|
127
|
+
# OSError: path doesn't exist, permission denied
|
|
128
|
+
# RuntimeError: symlink loop (Python <3.13)
|
|
129
|
+
msg = f"Failed to resolve path: {e}"
|
|
130
|
+
raise SecurityError(
|
|
131
|
+
code=ErrorCode.SECURITY_PATH_SYMLINK_LOOP,
|
|
132
|
+
message=msg,
|
|
133
|
+
context=security_context(path=str(full_path), root=str(resolved_root)),
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
# Critical security check: ensure resolved path is within root
|
|
137
|
+
try:
|
|
138
|
+
resolved_path.relative_to(resolved_root)
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
# relative_to raises ValueError if path is not relative to root
|
|
141
|
+
msg = "Path is outside allowed root directory"
|
|
142
|
+
raise SecurityError(
|
|
143
|
+
code=ErrorCode.SECURITY_PATH_OUTSIDE_ROOT,
|
|
144
|
+
message=msg,
|
|
145
|
+
context=security_context(path=str(resolved_path), root=str(resolved_root)),
|
|
146
|
+
) from e
|
|
147
|
+
|
|
148
|
+
# Check for Windows reserved names in any path component
|
|
149
|
+
_check_windows_reserved_names(resolved_path)
|
|
150
|
+
|
|
151
|
+
# Check path depth
|
|
152
|
+
_check_path_depth(resolved_path, resolved_root)
|
|
153
|
+
|
|
154
|
+
return resolved_path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_windows_reserved_names(path: Path) -> None:
|
|
158
|
+
"""Check if path contains Windows reserved device names.
|
|
159
|
+
|
|
160
|
+
Windows reserves certain names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) that
|
|
161
|
+
cannot be used as filenames. This applies even on Unix systems if files
|
|
162
|
+
will be synced to Windows.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
path: Path to check.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
SecurityError: If path contains a reserved name.
|
|
169
|
+
"""
|
|
170
|
+
for part in path.parts:
|
|
171
|
+
# Strip extension and check base name
|
|
172
|
+
name_upper = part.upper()
|
|
173
|
+
if "." in name_upper:
|
|
174
|
+
base_name = name_upper.split(".")[0]
|
|
175
|
+
else:
|
|
176
|
+
base_name = name_upper
|
|
177
|
+
|
|
178
|
+
if base_name in WINDOWS_RESERVED_NAMES:
|
|
179
|
+
msg = f"Path contains Windows reserved name: {part}"
|
|
180
|
+
raise SecurityError(
|
|
181
|
+
code=ErrorCode.SECURITY_PATH_RESERVED_NAME,
|
|
182
|
+
message=msg,
|
|
183
|
+
context=security_context(path=str(path), attack_type="windows_reserved_name"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_path_depth(path: Path, root: Path, max_depth: int = 20) -> None:
|
|
188
|
+
"""Check if path depth exceeds maximum allowed.
|
|
189
|
+
|
|
190
|
+
Prevents DoS attacks via extremely deep directory structures.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
path: Path to check.
|
|
194
|
+
root: Root directory (depth is measured from here).
|
|
195
|
+
max_depth: Maximum allowed depth (default 20).
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
SecurityError: If path depth exceeds limit.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
relative = path.relative_to(root)
|
|
202
|
+
depth = len(relative.parts)
|
|
203
|
+
if depth > max_depth:
|
|
204
|
+
msg = f"Path depth {depth} exceeds maximum {max_depth}"
|
|
205
|
+
raise SecurityError(
|
|
206
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
207
|
+
message=msg,
|
|
208
|
+
context=security_context(path=str(path), root=str(root)),
|
|
209
|
+
)
|
|
210
|
+
except ValueError:
|
|
211
|
+
# Path is not relative to root - already caught by safe_path
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_safe_path(root: Path, candidate: Path | str) -> bool:
|
|
216
|
+
"""Check if a path is safe without raising an exception.
|
|
217
|
+
|
|
218
|
+
Convenience wrapper around safe_path() for cases where you want to check
|
|
219
|
+
validity without exception handling.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
root: Root directory.
|
|
223
|
+
candidate: Path to check.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if path is safe, False otherwise.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
safe_path(root, candidate)
|
|
230
|
+
return True
|
|
231
|
+
except SecurityError:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def safe_join(root: Path, *parts: str) -> Path:
|
|
236
|
+
"""Join path components safely within a root directory.
|
|
237
|
+
|
|
238
|
+
Equivalent to root / part1 / part2 / ... but with safety validation.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
root: Root directory.
|
|
242
|
+
*parts: Path components to join.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Validated path within root.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
SecurityError: If resulting path would be outside root.
|
|
249
|
+
"""
|
|
250
|
+
if not parts:
|
|
251
|
+
return root
|
|
252
|
+
|
|
253
|
+
# Join all parts
|
|
254
|
+
candidate = Path(os.path.join(*parts))
|
|
255
|
+
|
|
256
|
+
# Validate
|
|
257
|
+
return safe_path(root, candidate, allow_create=True)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def validate_filename(filename: str) -> str:
|
|
261
|
+
"""Validate a filename for safety.
|
|
262
|
+
|
|
263
|
+
Checks that filename:
|
|
264
|
+
- Contains no path separators (/ or \\)
|
|
265
|
+
- Contains no null bytes
|
|
266
|
+
- Is not empty
|
|
267
|
+
- Is not a reserved name
|
|
268
|
+
- Contains only valid characters
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
filename: Filename to validate.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Validated filename.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
SecurityError: If filename is invalid.
|
|
278
|
+
"""
|
|
279
|
+
if not filename:
|
|
280
|
+
msg = "Filename cannot be empty"
|
|
281
|
+
raise SecurityError(
|
|
282
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
283
|
+
message=msg,
|
|
284
|
+
context={"filename": filename},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Check for path separators
|
|
288
|
+
if "/" in filename or "\\" in filename:
|
|
289
|
+
msg = "Filename cannot contain path separators"
|
|
290
|
+
raise SecurityError(
|
|
291
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
292
|
+
message=msg,
|
|
293
|
+
context={"filename": filename},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Check for null bytes
|
|
297
|
+
if "\x00" in filename:
|
|
298
|
+
msg = "Filename cannot contain null bytes"
|
|
299
|
+
raise SecurityError(
|
|
300
|
+
code=ErrorCode.SECURITY_PATH_INVALID,
|
|
301
|
+
message=msg,
|
|
302
|
+
context={"filename": filename},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Check for Windows reserved names
|
|
306
|
+
name_upper = filename.upper()
|
|
307
|
+
if "." in name_upper:
|
|
308
|
+
base_name = name_upper.split(".")[0]
|
|
309
|
+
else:
|
|
310
|
+
base_name = name_upper
|
|
311
|
+
|
|
312
|
+
if base_name in WINDOWS_RESERVED_NAMES:
|
|
313
|
+
msg = f"Filename is a Windows reserved name: {filename}"
|
|
314
|
+
raise SecurityError(
|
|
315
|
+
code=ErrorCode.SECURITY_PATH_RESERVED_NAME,
|
|
316
|
+
message=msg,
|
|
317
|
+
context={"filename": filename},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return filename
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def ensure_parent_exists(path: Path, *, permissions: int = 0o755) -> None:
|
|
324
|
+
"""Ensure parent directory of a path exists.
|
|
325
|
+
|
|
326
|
+
Creates parent directories if they don't exist, with proper permissions.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
path: File path whose parent should exist.
|
|
330
|
+
permissions: Permissions for created directories (default 0o755).
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
SecurityError: If directory creation fails.
|
|
334
|
+
"""
|
|
335
|
+
parent = path.parent
|
|
336
|
+
if parent.exists():
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
parent.mkdir(parents=True, exist_ok=True, mode=permissions)
|
|
341
|
+
except OSError as e:
|
|
342
|
+
msg = f"Failed to create parent directory: {e}"
|
|
343
|
+
raise SecurityError(
|
|
344
|
+
code=ErrorCode.SECURITY_WORKSPACE_INIT_FAILED,
|
|
345
|
+
message=msg,
|
|
346
|
+
context=security_context(path=str(parent)),
|
|
347
|
+
) from e
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Input sanitization utilities for preventing injection attacks.
|
|
2
|
+
|
|
3
|
+
This module provides sanitization functions to prevent:
|
|
4
|
+
- Unicode-based attacks (invisible characters, bidi overrides)
|
|
5
|
+
- SQL injection (via identifier validation)
|
|
6
|
+
- FTS5 injection (via query escaping)
|
|
7
|
+
- Prompt injection (via content wrapping)
|
|
8
|
+
|
|
9
|
+
All user-provided text inputs should be sanitized before use.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from re import Pattern
|
|
17
|
+
|
|
18
|
+
from contextual.core.errors import ErrorCode, SecurityError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Compiled regex patterns for performance
|
|
22
|
+
_SQL_IDENTIFIER_PATTERN: Pattern[str] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
23
|
+
_FTS5_SPECIAL_CHARS: Pattern[str] = re.compile(r'["@:()-]')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_unicode_control_chars(text: str) -> str:
|
|
27
|
+
"""Remove Unicode control characters from text.
|
|
28
|
+
|
|
29
|
+
Strips characters in Unicode category Cf (Format, Other), which includes:
|
|
30
|
+
- Zero-width characters (ZWSP, ZWNJ, ZWJ)
|
|
31
|
+
- Bidirectional overrides (RLO, LRO, PDF)
|
|
32
|
+
- Other invisible formatting characters
|
|
33
|
+
|
|
34
|
+
This prevents attacks like:
|
|
35
|
+
- Rules-File-Backdoor (hidden instructions visible only to LLMs)
|
|
36
|
+
- Visual spoofing via bidi overrides
|
|
37
|
+
- Zero-width character obfuscation
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
text: Text to sanitize.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Text with control characters removed.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
>>> text_with_zwsp = "Hello\\u200bWorld"
|
|
47
|
+
>>> strip_unicode_control_chars(text_with_zwsp)
|
|
48
|
+
'HelloWorld'
|
|
49
|
+
|
|
50
|
+
>>> text_with_bidi = "normal\\u202eesreveR" # RLO override
|
|
51
|
+
>>> strip_unicode_control_chars(text_with_bidi)
|
|
52
|
+
'normalesreveR'
|
|
53
|
+
"""
|
|
54
|
+
# Filter out Unicode category Cf (Format, Other)
|
|
55
|
+
# This is fast because we're iterating in Python and unicodedata.category
|
|
56
|
+
# is a C function call
|
|
57
|
+
return "".join(char for char in text if unicodedata.category(char) != "Cf")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def escape_fts5_match_query(query: str) -> str:
|
|
61
|
+
"""Escape a query string for SQLite FTS5 MATCH operator.
|
|
62
|
+
|
|
63
|
+
FTS5 has special syntax for queries (boolean AND/OR, phrase matching, etc.).
|
|
64
|
+
User input must be escaped to prevent injection and syntax errors.
|
|
65
|
+
|
|
66
|
+
Strategy: Phrase-quote the entire query, and escape internal quotes.
|
|
67
|
+
This treats the query as a literal phrase search, which is safe and
|
|
68
|
+
matches user expectations for a "search box" input.
|
|
69
|
+
|
|
70
|
+
FTS5 special characters that need escaping:
|
|
71
|
+
- " (phrase delimiter)
|
|
72
|
+
- @ (column filter)
|
|
73
|
+
- : (column filter)
|
|
74
|
+
- ( ) (grouping)
|
|
75
|
+
- - (exclusion)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
query: User-provided search query.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Escaped query safe for FTS5 MATCH.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
>>> escape_fts5_match_query('normal query')
|
|
85
|
+
'"normal query"'
|
|
86
|
+
|
|
87
|
+
>>> escape_fts5_match_query('query with "quotes"')
|
|
88
|
+
"\"query with \"\"quotes\"\"\""
|
|
89
|
+
|
|
90
|
+
>>> escape_fts5_match_query('special:chars@here')
|
|
91
|
+
'"special:chars@here"'
|
|
92
|
+
"""
|
|
93
|
+
# Escape internal quotes by doubling them
|
|
94
|
+
escaped = query.replace('"', '""')
|
|
95
|
+
|
|
96
|
+
# Wrap in phrase quotes
|
|
97
|
+
return f'"{escaped}"'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_sql_identifier(identifier: str) -> str:
|
|
101
|
+
"""Validate a SQL identifier (table name, column name, etc.).
|
|
102
|
+
|
|
103
|
+
Ensures identifier:
|
|
104
|
+
- Starts with letter or underscore
|
|
105
|
+
- Contains only letters, numbers, underscores
|
|
106
|
+
- Is not empty
|
|
107
|
+
- Is not too long (64 chars max for portability)
|
|
108
|
+
|
|
109
|
+
This prevents SQL injection via identifier names.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
identifier: SQL identifier to validate.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Validated identifier.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
SecurityError: If identifier is invalid.
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
>>> validate_sql_identifier('valid_name')
|
|
122
|
+
'valid_name'
|
|
123
|
+
|
|
124
|
+
>>> validate_sql_identifier('also123valid')
|
|
125
|
+
'also123valid'
|
|
126
|
+
|
|
127
|
+
>>> validate_sql_identifier('invalid-name') # Contains hyphen
|
|
128
|
+
SecurityError: Invalid SQL identifier
|
|
129
|
+
|
|
130
|
+
>>> validate_sql_identifier('123invalid') # Starts with number
|
|
131
|
+
SecurityError: Invalid SQL identifier
|
|
132
|
+
"""
|
|
133
|
+
if not identifier:
|
|
134
|
+
msg = "SQL identifier cannot be empty"
|
|
135
|
+
raise SecurityError(
|
|
136
|
+
code=ErrorCode.SECURITY_SQL_INJECTION_ATTEMPT,
|
|
137
|
+
message=msg,
|
|
138
|
+
context={"identifier": identifier},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if len(identifier) > 64:
|
|
142
|
+
msg = "SQL identifier too long (max 64 chars)"
|
|
143
|
+
raise SecurityError(
|
|
144
|
+
code=ErrorCode.SECURITY_SQL_INJECTION_ATTEMPT,
|
|
145
|
+
message=msg,
|
|
146
|
+
context={"identifier": identifier[:100]},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not _SQL_IDENTIFIER_PATTERN.match(identifier):
|
|
150
|
+
msg = "Invalid SQL identifier (must start with letter/underscore, contain only alphanumeric/underscore)"
|
|
151
|
+
raise SecurityError(
|
|
152
|
+
code=ErrorCode.SECURITY_SQL_INJECTION_ATTEMPT,
|
|
153
|
+
message=msg,
|
|
154
|
+
context={"identifier": identifier},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return identifier
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def sanitize_user_input(text: str, *, max_length: int | None = None) -> str:
|
|
161
|
+
"""Comprehensive sanitization of user-provided text.
|
|
162
|
+
|
|
163
|
+
Applies multiple sanitization steps:
|
|
164
|
+
1. Strip Unicode control characters
|
|
165
|
+
2. Normalize whitespace
|
|
166
|
+
3. Trim to max length if specified
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
text: User input to sanitize.
|
|
170
|
+
max_length: Optional maximum length (truncates if exceeded).
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Sanitized text.
|
|
174
|
+
"""
|
|
175
|
+
# Strip control characters
|
|
176
|
+
sanitized = strip_unicode_control_chars(text)
|
|
177
|
+
|
|
178
|
+
# Normalize whitespace (collapse multiple spaces, trim)
|
|
179
|
+
sanitized = " ".join(sanitized.split())
|
|
180
|
+
|
|
181
|
+
# Truncate if needed
|
|
182
|
+
if max_length is not None and len(sanitized) > max_length:
|
|
183
|
+
sanitized = sanitized[:max_length]
|
|
184
|
+
|
|
185
|
+
return sanitized
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def wrap_untrusted_content(content: str) -> str:
|
|
189
|
+
"""Wrap potentially untrusted content in safety markers.
|
|
190
|
+
|
|
191
|
+
When passing indexed code/content to LLMs via MCP tools, we wrap it
|
|
192
|
+
in markers to indicate it should be treated as data, not instructions.
|
|
193
|
+
|
|
194
|
+
This mitigates indirect prompt injection where malicious code files
|
|
195
|
+
contain hidden instructions that the LLM might follow.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content: Content to wrap.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Content wrapped in safety markers.
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
>>> wrap_untrusted_content('code snippet')
|
|
205
|
+
'<untrusted_content>\\ncode snippet\\n</untrusted_content>'
|
|
206
|
+
"""
|
|
207
|
+
return f"<untrusted_content>\n{content}\n</untrusted_content>"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def detect_prompt_injection_patterns(text: str) -> list[str]:
|
|
211
|
+
"""Detect common prompt injection patterns in text.
|
|
212
|
+
|
|
213
|
+
This is a heuristic check for known prompt injection techniques.
|
|
214
|
+
Not foolproof, but catches obvious attempts.
|
|
215
|
+
|
|
216
|
+
Patterns checked:
|
|
217
|
+
- "Ignore previous instructions"
|
|
218
|
+
- "You are now in [X] mode"
|
|
219
|
+
- "Disregard your rules"
|
|
220
|
+
- "New instructions:"
|
|
221
|
+
- Excessive repetition (token flooding)
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
text: Text to check.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
List of detected pattern descriptions (empty if none found).
|
|
228
|
+
"""
|
|
229
|
+
patterns_found: list[str] = []
|
|
230
|
+
|
|
231
|
+
text_lower = text.lower()
|
|
232
|
+
|
|
233
|
+
# Check for instruction override phrases
|
|
234
|
+
override_phrases = [
|
|
235
|
+
"ignore previous instructions",
|
|
236
|
+
"ignore all previous",
|
|
237
|
+
"disregard your rules",
|
|
238
|
+
"disregard all rules",
|
|
239
|
+
"new instructions:",
|
|
240
|
+
"you are now",
|
|
241
|
+
"enter god mode",
|
|
242
|
+
"enter admin mode",
|
|
243
|
+
"system: ",
|
|
244
|
+
"<system>",
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
for phrase in override_phrases:
|
|
248
|
+
if phrase in text_lower:
|
|
249
|
+
patterns_found.append(f"Instruction override phrase: {phrase}")
|
|
250
|
+
|
|
251
|
+
# Check for excessive repetition (token flooding)
|
|
252
|
+
words = text.split()
|
|
253
|
+
if len(words) > 50:
|
|
254
|
+
# Check if any word is repeated excessively
|
|
255
|
+
word_counts: dict[str, int] = {}
|
|
256
|
+
for word in words:
|
|
257
|
+
word_lower = word.lower()
|
|
258
|
+
word_counts[word_lower] = word_counts.get(word_lower, 0) + 1
|
|
259
|
+
|
|
260
|
+
for word, count in word_counts.items():
|
|
261
|
+
if count > 10 and len(word) > 3: # Word repeated >10 times
|
|
262
|
+
patterns_found.append(f"Excessive repetition: '{word}' repeated {count} times")
|
|
263
|
+
|
|
264
|
+
# Check for XML/HTML-like tags that might interfere with parsing
|
|
265
|
+
suspicious_tags = [
|
|
266
|
+
"<system", "<instruction", "<prompt", "<rule", "<command",
|
|
267
|
+
"</system", "</instruction", "</prompt", "</rule", "</command",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
for tag in suspicious_tags:
|
|
271
|
+
if tag in text_lower:
|
|
272
|
+
patterns_found.append(f"Suspicious tag: {tag}")
|
|
273
|
+
|
|
274
|
+
return patterns_found
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def sanitize_file_content(
|
|
278
|
+
content: str,
|
|
279
|
+
*,
|
|
280
|
+
strip_control: bool = True,
|
|
281
|
+
detect_injection: bool = True,
|
|
282
|
+
max_length: int | None = None,
|
|
283
|
+
) -> tuple[str, list[str]]:
|
|
284
|
+
"""Sanitize file content before indexing or display.
|
|
285
|
+
|
|
286
|
+
Applies sanitization and optionally checks for injection patterns.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
content: File content to sanitize.
|
|
290
|
+
strip_control: Whether to strip Unicode control characters.
|
|
291
|
+
detect_injection: Whether to detect prompt injection patterns.
|
|
292
|
+
max_length: Optional maximum length.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Tuple of (sanitized_content, warnings).
|
|
296
|
+
Warnings list contains descriptions of detected issues.
|
|
297
|
+
"""
|
|
298
|
+
warnings: list[str] = []
|
|
299
|
+
|
|
300
|
+
# Strip control characters if requested
|
|
301
|
+
if strip_control:
|
|
302
|
+
original_length = len(content)
|
|
303
|
+
content = strip_unicode_control_chars(content)
|
|
304
|
+
removed = original_length - len(content)
|
|
305
|
+
if removed > 0:
|
|
306
|
+
warnings.append(f"Removed {removed} Unicode control characters")
|
|
307
|
+
|
|
308
|
+
# Detect injection patterns if requested
|
|
309
|
+
if detect_injection:
|
|
310
|
+
patterns = detect_prompt_injection_patterns(content)
|
|
311
|
+
warnings.extend(patterns)
|
|
312
|
+
|
|
313
|
+
# Truncate if needed
|
|
314
|
+
if max_length is not None and len(content) > max_length:
|
|
315
|
+
content = content[:max_length]
|
|
316
|
+
warnings.append(f"Content truncated to {max_length} characters")
|
|
317
|
+
|
|
318
|
+
return content, warnings
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def escape_for_logging(text: str, max_length: int = 200) -> str:
|
|
322
|
+
"""Escape text for safe inclusion in logs.
|
|
323
|
+
|
|
324
|
+
Ensures logged text:
|
|
325
|
+
- Has no control characters
|
|
326
|
+
- Is truncated to reasonable length
|
|
327
|
+
- Has no newlines (prevents log injection)
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
text: Text to escape.
|
|
331
|
+
max_length: Maximum length (default 200).
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Escaped text safe for logging.
|
|
335
|
+
"""
|
|
336
|
+
# Strip control characters
|
|
337
|
+
escaped = strip_unicode_control_chars(text)
|
|
338
|
+
|
|
339
|
+
# Replace newlines with spaces
|
|
340
|
+
escaped = escaped.replace("\n", " ").replace("\r", " ")
|
|
341
|
+
|
|
342
|
+
# Collapse multiple spaces
|
|
343
|
+
escaped = " ".join(escaped.split())
|
|
344
|
+
|
|
345
|
+
# Truncate
|
|
346
|
+
if len(escaped) > max_length:
|
|
347
|
+
escaped = escaped[:max_length] + "..."
|
|
348
|
+
|
|
349
|
+
return escaped
|