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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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