mcpower-proxy 0.0.58__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.
- main.py +112 -0
- mcpower_proxy-0.0.58.dist-info/METADATA +250 -0
- mcpower_proxy-0.0.58.dist-info/RECORD +43 -0
- mcpower_proxy-0.0.58.dist-info/WHEEL +5 -0
- mcpower_proxy-0.0.58.dist-info/entry_points.txt +2 -0
- mcpower_proxy-0.0.58.dist-info/licenses/LICENSE +201 -0
- mcpower_proxy-0.0.58.dist-info/top_level.txt +3 -0
- modules/__init__.py +1 -0
- modules/apis/__init__.py +1 -0
- modules/apis/security_policy.py +322 -0
- modules/logs/__init__.py +1 -0
- modules/logs/audit_trail.py +162 -0
- modules/logs/logger.py +128 -0
- modules/redaction/__init__.py +13 -0
- modules/redaction/constants.py +38 -0
- modules/redaction/gitleaks_rules.py +1268 -0
- modules/redaction/pii_rules.py +271 -0
- modules/redaction/redactor.py +599 -0
- modules/ui/__init__.py +1 -0
- modules/ui/classes.py +48 -0
- modules/ui/confirmation.py +200 -0
- modules/ui/simple_dialog.py +104 -0
- modules/ui/xdialog/__init__.py +249 -0
- modules/ui/xdialog/constants.py +13 -0
- modules/ui/xdialog/mac_dialogs.py +190 -0
- modules/ui/xdialog/tk_dialogs.py +78 -0
- modules/ui/xdialog/windows_custom_dialog.py +426 -0
- modules/ui/xdialog/windows_dialogs.py +250 -0
- modules/ui/xdialog/windows_structs.py +183 -0
- modules/ui/xdialog/yad_dialogs.py +236 -0
- modules/ui/xdialog/zenity_dialogs.py +156 -0
- modules/utils/__init__.py +1 -0
- modules/utils/cli.py +46 -0
- modules/utils/config.py +193 -0
- modules/utils/copy.py +36 -0
- modules/utils/ids.py +160 -0
- modules/utils/json.py +120 -0
- modules/utils/mcp_configs.py +48 -0
- wrapper/__init__.py +1 -0
- wrapper/__version__.py +6 -0
- wrapper/middleware.py +750 -0
- wrapper/schema.py +227 -0
- wrapper/server.py +78 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client-side redaction implementation.
|
|
3
|
+
|
|
4
|
+
Input string → output string with all PII and secrets redacted using Gitleaks-based secret detection
|
|
5
|
+
and regex patterns for PII. Fully offline, deterministic, and idempotent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Any, Optional, Tuple, Set, Dict
|
|
12
|
+
|
|
13
|
+
from jsonpath_ng import parse as jsonpath_parse
|
|
14
|
+
|
|
15
|
+
from .constants import (
|
|
16
|
+
PII_PLACEHOLDERS,
|
|
17
|
+
SECRETS_PLACEHOLDER,
|
|
18
|
+
REDACTION_PLACEHOLDER_PATTERN,
|
|
19
|
+
ZERO_WIDTH_CHARS
|
|
20
|
+
)
|
|
21
|
+
from .pii_rules import detect_pii
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Type alias for JSONPath cache values: (matches_set, prefix_tree)
|
|
25
|
+
JSONPathCacheValue = Tuple[Set[str], Dict[str, Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LRUCache:
|
|
29
|
+
"""
|
|
30
|
+
Least Recently Used cache with size limit to prevent unbounded memory growth.
|
|
31
|
+
Thread-safe for single-threaded usage (not thread-safe for concurrent access).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_size: int = 128):
|
|
35
|
+
self.cache = OrderedDict()
|
|
36
|
+
self.max_size = max_size
|
|
37
|
+
|
|
38
|
+
def get(self, key) -> Optional[JSONPathCacheValue]:
|
|
39
|
+
"""Get value and mark as recently used."""
|
|
40
|
+
if key in self.cache:
|
|
41
|
+
# Move to end (most recently used)
|
|
42
|
+
self.cache.move_to_end(key)
|
|
43
|
+
return self.cache[key]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def set(self, key, value: JSONPathCacheValue) -> None:
|
|
47
|
+
"""Set value and evict oldest if necessary."""
|
|
48
|
+
if key in self.cache:
|
|
49
|
+
# Update existing key
|
|
50
|
+
self.cache[key] = value
|
|
51
|
+
self.cache.move_to_end(key)
|
|
52
|
+
else:
|
|
53
|
+
# Add new key
|
|
54
|
+
self.cache[key] = value
|
|
55
|
+
# Evict oldest if over limit
|
|
56
|
+
if len(self.cache) > self.max_size:
|
|
57
|
+
self.cache.popitem(last=False) # Remove oldest (first) item
|
|
58
|
+
|
|
59
|
+
def clear(self) -> None:
|
|
60
|
+
"""Clear all cached entries."""
|
|
61
|
+
self.cache.clear()
|
|
62
|
+
|
|
63
|
+
def size(self) -> int:
|
|
64
|
+
"""Get current cache size."""
|
|
65
|
+
return len(self.cache)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# noinspection PyClassHasNoInit
|
|
69
|
+
@dataclass
|
|
70
|
+
class RedactionSpan:
|
|
71
|
+
"""Represents a span of text to be redacted."""
|
|
72
|
+
start: int
|
|
73
|
+
end: int
|
|
74
|
+
replacement: str
|
|
75
|
+
source: str # 'secrets' or 'pii'
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def length(self) -> int:
|
|
79
|
+
return self.end - self.start
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RedactionEngine:
|
|
83
|
+
"""Core redaction engine using PII detection and Gitleaks."""
|
|
84
|
+
|
|
85
|
+
# Performance optimization: Simple bounded cache for compiled JSONPath expressions
|
|
86
|
+
_jsonpath_expr_cache = {}
|
|
87
|
+
_MAX_EXPR_CACHE = 64
|
|
88
|
+
|
|
89
|
+
# Performance optimization: LRU cache for JSONPath matches to prevent memory leaks
|
|
90
|
+
_jsonpath_cache = LRUCache(max_size=128)
|
|
91
|
+
|
|
92
|
+
# Performance optimization: Pre-compiled regex patterns
|
|
93
|
+
_compiled_regexes = {
|
|
94
|
+
'placeholder': re.compile(REDACTION_PLACEHOLDER_PATTERN),
|
|
95
|
+
'path_brackets': re.compile(r'\.\[(\d+)\]')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def redact(value: Any, ignored_keys: List[str] = None, include_keys: List[str] = None) -> Any:
|
|
100
|
+
"""
|
|
101
|
+
Main redaction function that handles any data type.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
value: Input data to redact (dict, list, str, int, float, bool, etc.)
|
|
105
|
+
ignored_keys: Optional list of dot-notation paths to ignore during redaction
|
|
106
|
+
include_keys: Optional list of dot-notation paths to redact (all others ignored)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Data with PII and secrets replaced by placeholders, preserving original types
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: If both ignored_keys and include_keys are provided
|
|
113
|
+
"""
|
|
114
|
+
if ignored_keys and include_keys:
|
|
115
|
+
raise ValueError("Cannot specify both ignored_keys and include_keys - use one or the other")
|
|
116
|
+
|
|
117
|
+
return RedactionEngine._redact_with_path(value, ignored_keys or [], include_keys or [], "", value)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _redact_with_path(value: Any, ignored_keys: List[str], include_keys: List[str], current_path: str, root_data: Any) -> Any:
|
|
121
|
+
"""
|
|
122
|
+
Internal redaction function that tracks the current path.
|
|
123
|
+
"""
|
|
124
|
+
# Performance optimization: fastest type checks first
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
if isinstance(value, bool):
|
|
129
|
+
return value # Booleans can never contain sensitive data - fastest check
|
|
130
|
+
|
|
131
|
+
if isinstance(value, (int, float)):
|
|
132
|
+
# Preserve type if no redaction needed
|
|
133
|
+
str_value = str(value)
|
|
134
|
+
redacted_str = RedactionEngine._redact_string(str_value)
|
|
135
|
+
return value if str_value == redacted_str else redacted_str
|
|
136
|
+
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
return RedactionEngine._redact_string(value)
|
|
139
|
+
|
|
140
|
+
if isinstance(value, dict):
|
|
141
|
+
return RedactionEngine._redact_dict(value, ignored_keys, include_keys, current_path, root_data)
|
|
142
|
+
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
return RedactionEngine._redact_list(value, ignored_keys, include_keys, current_path, root_data)
|
|
145
|
+
|
|
146
|
+
# For other types, convert to string and redact
|
|
147
|
+
return RedactionEngine._redact_string(str(value))
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _normalize_text(text: str) -> str:
|
|
151
|
+
"""Strip zero-width characters efficiently."""
|
|
152
|
+
# Fast path - check if normalization is needed
|
|
153
|
+
if not any(char in text for char in ZERO_WIDTH_CHARS):
|
|
154
|
+
return text
|
|
155
|
+
|
|
156
|
+
# Single pass with translation table (much faster)
|
|
157
|
+
translation_table = str.maketrans('', '', ''.join(ZERO_WIDTH_CHARS))
|
|
158
|
+
return text.translate(translation_table)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _detect_secrets(text: str) -> List[RedactionSpan]:
|
|
162
|
+
"""Detect secrets using Gitleaks-based patterns."""
|
|
163
|
+
spans = []
|
|
164
|
+
|
|
165
|
+
# Use compiled rules (based on Gitleaks patterns) for secrets detection
|
|
166
|
+
from . import gitleaks_rules as GL
|
|
167
|
+
# Scan line-by-line for performance and to reduce pathological matches
|
|
168
|
+
offset = 0
|
|
169
|
+
for line in text.split('\n'):
|
|
170
|
+
line_lower = line.lower()
|
|
171
|
+
candidate_indices = GL.candidate_rule_indices(line_lower)
|
|
172
|
+
if candidate_indices and line:
|
|
173
|
+
for idx in candidate_indices:
|
|
174
|
+
_, regex, secret_group, _ = GL.COMPILED_RULES[idx]
|
|
175
|
+
for match in regex.finditer(line):
|
|
176
|
+
# Use the specified group, or fall back to group 0 if it doesn't exist
|
|
177
|
+
try:
|
|
178
|
+
if secret_group and match.lastindex and secret_group <= match.lastindex:
|
|
179
|
+
s, e = match.span(secret_group)
|
|
180
|
+
else:
|
|
181
|
+
s, e = match.span(0)
|
|
182
|
+
except (IndexError, AttributeError):
|
|
183
|
+
s, e = match.span(0)
|
|
184
|
+
|
|
185
|
+
if s < e:
|
|
186
|
+
spans.append(RedactionSpan(
|
|
187
|
+
start=offset + s,
|
|
188
|
+
end=offset + e,
|
|
189
|
+
replacement=SECRETS_PLACEHOLDER,
|
|
190
|
+
source='secrets'
|
|
191
|
+
))
|
|
192
|
+
offset += len(line) + 1 # +1 for the split '\n'
|
|
193
|
+
|
|
194
|
+
return spans
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _detect_pii(text: str) -> List[RedactionSpan]:
|
|
198
|
+
"""Detect PII using regex patterns."""
|
|
199
|
+
spans = []
|
|
200
|
+
|
|
201
|
+
# Use PII detection
|
|
202
|
+
results = detect_pii(text)
|
|
203
|
+
|
|
204
|
+
# Convert results to redaction spans
|
|
205
|
+
for result in results:
|
|
206
|
+
placeholder = PII_PLACEHOLDERS.get(
|
|
207
|
+
result.entity_type,
|
|
208
|
+
PII_PLACEHOLDERS["DEFAULT"]
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
spans.append(RedactionSpan(
|
|
212
|
+
start=result.start,
|
|
213
|
+
end=result.end,
|
|
214
|
+
replacement=placeholder,
|
|
215
|
+
source='pii'
|
|
216
|
+
))
|
|
217
|
+
|
|
218
|
+
return spans
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _resolve_overlaps(spans: List[RedactionSpan]) -> List[RedactionSpan]:
|
|
222
|
+
"""Resolve overlapping spans - longest span wins."""
|
|
223
|
+
if not spans:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
# Sort by start position
|
|
227
|
+
sorted_spans = sorted(spans, key=lambda s: s.start)
|
|
228
|
+
resolved = []
|
|
229
|
+
|
|
230
|
+
for current_span in sorted_spans:
|
|
231
|
+
# Check for overlaps with already resolved spans
|
|
232
|
+
overlaps = False
|
|
233
|
+
|
|
234
|
+
for i, existing_span in enumerate(resolved):
|
|
235
|
+
if RedactionEngine._spans_overlap(current_span, existing_span):
|
|
236
|
+
overlaps = True
|
|
237
|
+
# Keep the longer span
|
|
238
|
+
if current_span.length > existing_span.length:
|
|
239
|
+
resolved[i] = current_span
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
if not overlaps:
|
|
243
|
+
resolved.append(current_span)
|
|
244
|
+
|
|
245
|
+
return resolved
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _spans_overlap(span1: RedactionSpan, span2: RedactionSpan) -> bool:
|
|
249
|
+
"""Check if two spans overlap."""
|
|
250
|
+
return not (span1.end <= span2.start or span2.end <= span1.start)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _apply_idempotency_guard(text: str, spans: List[RedactionSpan]) -> List[RedactionSpan]:
|
|
254
|
+
"""Remove spans that would redact inside existing placeholders."""
|
|
255
|
+
if not spans:
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
# Find existing redaction placeholders
|
|
259
|
+
placeholder_spans = []
|
|
260
|
+
# Performance optimization: Use pre-compiled regex
|
|
261
|
+
for match in RedactionEngine._compiled_regexes['placeholder'].finditer(text):
|
|
262
|
+
placeholder_spans.append((match.start(), match.end()))
|
|
263
|
+
|
|
264
|
+
# Filter out spans that overlap with existing placeholders
|
|
265
|
+
filtered_spans = []
|
|
266
|
+
for span in spans:
|
|
267
|
+
overlaps_placeholder = False
|
|
268
|
+
|
|
269
|
+
for ph_start, ph_end in placeholder_spans:
|
|
270
|
+
if not (span.end <= ph_start or span.start >= ph_end):
|
|
271
|
+
overlaps_placeholder = True
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
if not overlaps_placeholder:
|
|
275
|
+
filtered_spans.append(span)
|
|
276
|
+
|
|
277
|
+
return filtered_spans
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _apply_redactions(text: str, spans: List[RedactionSpan]) -> str:
|
|
281
|
+
"""Apply redactions right-to-left to avoid index shifting, preserving JSON structure."""
|
|
282
|
+
if not spans:
|
|
283
|
+
return text
|
|
284
|
+
|
|
285
|
+
# Sort spans by start position (descending) for right-to-left processing
|
|
286
|
+
# Processing right-to-left ensures earlier spans' positions remain valid
|
|
287
|
+
# since replacements to the right don't affect positions to the left
|
|
288
|
+
sorted_spans = sorted(spans, key=lambda s: s.start, reverse=True)
|
|
289
|
+
|
|
290
|
+
result = text
|
|
291
|
+
|
|
292
|
+
for span in sorted_spans:
|
|
293
|
+
# Replacements to the right don't shift positions to the left
|
|
294
|
+
start_pos = span.start
|
|
295
|
+
end_pos = span.end
|
|
296
|
+
|
|
297
|
+
# Bounds check
|
|
298
|
+
if start_pos < 0 or end_pos > len(result) or start_pos >= len(result):
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
replacement = span.replacement
|
|
302
|
+
|
|
303
|
+
# Simply replace the matched text with the redaction placeholder
|
|
304
|
+
# Do NOT expand to surrounding quotes - this breaks nested JSON
|
|
305
|
+
# The redaction placeholders are designed to be valid string content as-is
|
|
306
|
+
|
|
307
|
+
# Apply the redaction
|
|
308
|
+
result = result[:start_pos] + replacement + result[end_pos:]
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _redact_dict(d: dict, ignored_keys: List[str], include_keys: List[str], current_path: str, root_data: Any) -> dict:
|
|
314
|
+
"""Redact dictionary values, respecting ignored_keys or include_keys."""
|
|
315
|
+
result = {}
|
|
316
|
+
|
|
317
|
+
for key, value in d.items():
|
|
318
|
+
# Build the path for this key
|
|
319
|
+
key_path = RedactionEngine._build_path(current_path, str(key))
|
|
320
|
+
|
|
321
|
+
# Determine if this path should be redacted
|
|
322
|
+
if RedactionEngine._should_redact_path_enhanced(root_data, key_path, ignored_keys, include_keys, current_path):
|
|
323
|
+
# Recursively redact the value with updated path context
|
|
324
|
+
result[key] = RedactionEngine._redact_with_path(value, ignored_keys, include_keys, key_path, root_data)
|
|
325
|
+
else:
|
|
326
|
+
# Keep the value as-is (no redaction for this path or any nested paths)
|
|
327
|
+
result[key] = value
|
|
328
|
+
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _redact_list(lst: list, ignored_keys: List[str], include_keys: List[str], current_path: str, root_data: Any) -> list:
|
|
333
|
+
"""Redact list items, respecting ignored_keys or include_keys."""
|
|
334
|
+
result = []
|
|
335
|
+
|
|
336
|
+
for i, item in enumerate(lst):
|
|
337
|
+
# Build the path for this index
|
|
338
|
+
item_path = RedactionEngine._build_path(current_path, str(i))
|
|
339
|
+
|
|
340
|
+
# Determine if this path should be redacted
|
|
341
|
+
if RedactionEngine._should_redact_path_enhanced(root_data, item_path, ignored_keys, include_keys, current_path):
|
|
342
|
+
# Recursively redact the item with updated path context
|
|
343
|
+
result.append(RedactionEngine._redact_with_path(item, ignored_keys, include_keys, item_path, root_data))
|
|
344
|
+
else:
|
|
345
|
+
# Keep the item as-is (no redaction for this path or any nested paths)
|
|
346
|
+
result.append(item)
|
|
347
|
+
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _redact_string(text: str) -> str:
|
|
352
|
+
"""Redact a string using the existing redaction pipeline."""
|
|
353
|
+
if not text or not isinstance(text, str):
|
|
354
|
+
return text
|
|
355
|
+
|
|
356
|
+
normalized_text = RedactionEngine._normalize_text(text)
|
|
357
|
+
redaction_spans: List[RedactionSpan] = []
|
|
358
|
+
redaction_spans.extend(RedactionEngine._detect_secrets(normalized_text))
|
|
359
|
+
redaction_spans.extend(RedactionEngine._detect_pii(normalized_text))
|
|
360
|
+
resolved_spans = RedactionEngine._resolve_overlaps(redaction_spans)
|
|
361
|
+
final_spans = RedactionEngine._apply_idempotency_guard(normalized_text, resolved_spans)
|
|
362
|
+
return RedactionEngine._apply_redactions(normalized_text, final_spans)
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _normalize_numeric_key(key: str) -> str:
|
|
366
|
+
"""Normalize numeric keys efficiently - only if needed."""
|
|
367
|
+
if key and key[0] == '0' and len(key) > 1 and key.isdigit():
|
|
368
|
+
# Only normalize if there are leading zeros
|
|
369
|
+
return str(int(key))
|
|
370
|
+
return key
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _build_path(current_path: str, key: str) -> str:
|
|
374
|
+
"""
|
|
375
|
+
Build a dot-notation path with consistent numeric key formatting.
|
|
376
|
+
Normalizes numeric indices to remove leading zeros for consistency.
|
|
377
|
+
Handles empty keys gracefully to prevent malformed paths.
|
|
378
|
+
"""
|
|
379
|
+
# Normalize numeric keys efficiently
|
|
380
|
+
normalized_key = RedactionEngine._normalize_numeric_key(key)
|
|
381
|
+
|
|
382
|
+
# Handle empty keys to prevent paths like "user..email"
|
|
383
|
+
if not normalized_key:
|
|
384
|
+
return current_path
|
|
385
|
+
|
|
386
|
+
if not current_path:
|
|
387
|
+
return normalized_key
|
|
388
|
+
|
|
389
|
+
return f"{current_path}.{normalized_key}"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _should_redact_path_enhanced(root_data: dict, path: str, ignored_keys: List[str], include_keys: List[str], current_path: str) -> bool:
|
|
394
|
+
"""
|
|
395
|
+
Enhanced path matching using JSONPath when available, fallback to custom logic.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
root_data: The root data object for JSONPath queries
|
|
399
|
+
path: The current path (e.g., "user.email")
|
|
400
|
+
ignored_keys: Paths to ignore (don't redact)
|
|
401
|
+
include_keys: Paths to include (only redact these)
|
|
402
|
+
current_path: Current traversal path
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
True if the path should be redacted, False otherwise
|
|
406
|
+
"""
|
|
407
|
+
return RedactionEngine._should_redact_path_jsonpath(root_data, path, ignored_keys, include_keys)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _should_redact_path_jsonpath(root_data: dict, path: str, ignored_keys: List[str], include_keys: List[str]) -> bool:
|
|
411
|
+
"""
|
|
412
|
+
JSONPath-based path matching with LRU cache to prevent memory leaks.
|
|
413
|
+
"""
|
|
414
|
+
# Performance optimization: Use frozenset for better cache key performance
|
|
415
|
+
cache_key = (
|
|
416
|
+
id(root_data),
|
|
417
|
+
frozenset(ignored_keys) if ignored_keys else None,
|
|
418
|
+
frozenset(include_keys) if include_keys else None
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Try to get from LRU cache
|
|
422
|
+
cached_result = RedactionEngine._jsonpath_cache.get(cache_key)
|
|
423
|
+
|
|
424
|
+
if cached_result is None:
|
|
425
|
+
# Pre-compute all matches for this data/pattern combination
|
|
426
|
+
all_matches = set()
|
|
427
|
+
patterns = include_keys or ignored_keys or []
|
|
428
|
+
|
|
429
|
+
for pattern in patterns:
|
|
430
|
+
try:
|
|
431
|
+
# Expect proper JSONPath format (starting with $)
|
|
432
|
+
if not pattern.startswith('$'):
|
|
433
|
+
raise ValueError(f"Pattern must be in JSONPath format (start with $): {pattern}")
|
|
434
|
+
|
|
435
|
+
# Performance optimization: Use cached compiled expressions
|
|
436
|
+
if pattern not in RedactionEngine._jsonpath_expr_cache:
|
|
437
|
+
# Simple FIFO eviction if cache is full
|
|
438
|
+
if len(RedactionEngine._jsonpath_expr_cache) >= RedactionEngine._MAX_EXPR_CACHE:
|
|
439
|
+
# Remove oldest entry (first in dict)
|
|
440
|
+
oldest_key = next(iter(RedactionEngine._jsonpath_expr_cache))
|
|
441
|
+
del RedactionEngine._jsonpath_expr_cache[oldest_key]
|
|
442
|
+
RedactionEngine._jsonpath_expr_cache[pattern] = jsonpath_parse(pattern)
|
|
443
|
+
jsonpath_expr = RedactionEngine._jsonpath_expr_cache[pattern]
|
|
444
|
+
|
|
445
|
+
matches = jsonpath_expr.find(root_data)
|
|
446
|
+
|
|
447
|
+
for match in matches:
|
|
448
|
+
# Performance optimization: Single regex for path conversion
|
|
449
|
+
match_path = RedactionEngine._compiled_regexes['path_brackets'].sub(
|
|
450
|
+
r'.\1', str(match.full_path)
|
|
451
|
+
)
|
|
452
|
+
all_matches.add(match_path)
|
|
453
|
+
|
|
454
|
+
except Exception:
|
|
455
|
+
# Fallback for invalid JSONPath patterns
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
# Build prefix tree for O(k) child matching
|
|
459
|
+
prefix_tree = RedactionEngine._build_prefix_tree(all_matches)
|
|
460
|
+
cached_result = (all_matches, prefix_tree)
|
|
461
|
+
|
|
462
|
+
# Store in LRU cache (will evict oldest if necessary)
|
|
463
|
+
RedactionEngine._jsonpath_cache.set(cache_key, cached_result)
|
|
464
|
+
|
|
465
|
+
matches, prefix_tree = cached_result
|
|
466
|
+
|
|
467
|
+
if include_keys:
|
|
468
|
+
# include_keys mode: only redact if path matches or has matching children
|
|
469
|
+
return RedactionEngine._path_matches_or_has_children(path, prefix_tree)
|
|
470
|
+
elif ignored_keys:
|
|
471
|
+
# ignored_keys mode: redact everything except matches and their children
|
|
472
|
+
return not RedactionEngine._path_matches_or_has_children(path, prefix_tree)
|
|
473
|
+
else:
|
|
474
|
+
# No filtering: redact everything
|
|
475
|
+
return True
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _build_prefix_tree(matches: Set[str]) -> Dict[str, Any]:
|
|
479
|
+
"""
|
|
480
|
+
Build a prefix tree for O(k) lookups with exact match tracking.
|
|
481
|
+
Stores both exact matches and children for single-traversal lookup.
|
|
482
|
+
"""
|
|
483
|
+
prefix_tree = {}
|
|
484
|
+
|
|
485
|
+
for match in matches:
|
|
486
|
+
parts = match.split('.')
|
|
487
|
+
node = prefix_tree
|
|
488
|
+
|
|
489
|
+
# Build tree path
|
|
490
|
+
for i, part in enumerate(parts):
|
|
491
|
+
if part not in node:
|
|
492
|
+
node[part] = {'_children': {}, '_is_match': False}
|
|
493
|
+
|
|
494
|
+
if i == len(parts) - 1:
|
|
495
|
+
node[part]['_is_match'] = True # Mark exact matches
|
|
496
|
+
|
|
497
|
+
node = node[part]['_children']
|
|
498
|
+
|
|
499
|
+
return prefix_tree
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _has_matching_children_optimized(path: str, prefix_tree: Dict[str, Any]) -> bool:
|
|
503
|
+
"""
|
|
504
|
+
Check if any matches are children of this path using prefix tree.
|
|
505
|
+
Time complexity: O(k) where k is path depth, vs O(n) for linear search.
|
|
506
|
+
"""
|
|
507
|
+
if not path:
|
|
508
|
+
# Root path - check if tree has any entries
|
|
509
|
+
return bool(prefix_tree)
|
|
510
|
+
|
|
511
|
+
# Navigate to the node representing this path
|
|
512
|
+
parts = path.split('.')
|
|
513
|
+
node = prefix_tree
|
|
514
|
+
|
|
515
|
+
for part in parts:
|
|
516
|
+
if part not in node:
|
|
517
|
+
# Path doesn't exist in tree
|
|
518
|
+
return False
|
|
519
|
+
node = node[part]['_children']
|
|
520
|
+
|
|
521
|
+
# Check if there are any children under this path
|
|
522
|
+
return bool(node)
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _path_matches_or_has_children(path: str, prefix_tree: Dict[str, Any]) -> bool:
|
|
526
|
+
"""
|
|
527
|
+
Single traversal for both exact match and children check.
|
|
528
|
+
More efficient than separate set lookup + tree traversal.
|
|
529
|
+
"""
|
|
530
|
+
if not path:
|
|
531
|
+
return bool(prefix_tree)
|
|
532
|
+
|
|
533
|
+
parts = path.split('.')
|
|
534
|
+
node = prefix_tree
|
|
535
|
+
|
|
536
|
+
for i, part in enumerate(parts):
|
|
537
|
+
if part not in node:
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
if i == len(parts) - 1:
|
|
541
|
+
# Check if this exact path matches OR has children
|
|
542
|
+
return node[part].get('_is_match', False) or bool(node[part]['_children'])
|
|
543
|
+
|
|
544
|
+
node = node[part]['_children']
|
|
545
|
+
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
@staticmethod
|
|
549
|
+
def _has_matching_children_cached(path: str, matches: Set[str]) -> bool:
|
|
550
|
+
"""
|
|
551
|
+
Legacy method - kept for backward compatibility.
|
|
552
|
+
Check if any cached matches are children of this path.
|
|
553
|
+
"""
|
|
554
|
+
path_prefix = path + "." if path else ""
|
|
555
|
+
return any(match.startswith(path_prefix) for match in matches)
|
|
556
|
+
|
|
557
|
+
@classmethod
|
|
558
|
+
def clear_caches(cls) -> None:
|
|
559
|
+
"""
|
|
560
|
+
Clear all caches to free memory. Useful for testing or memory management.
|
|
561
|
+
"""
|
|
562
|
+
cls._jsonpath_expr_cache.clear()
|
|
563
|
+
cls._jsonpath_cache.clear()
|
|
564
|
+
|
|
565
|
+
@classmethod
|
|
566
|
+
def get_cache_stats(cls) -> Dict[str, int]:
|
|
567
|
+
"""
|
|
568
|
+
Get cache statistics for monitoring and debugging.
|
|
569
|
+
"""
|
|
570
|
+
return {
|
|
571
|
+
'jsonpath_expr_cache_size': len(cls._jsonpath_expr_cache),
|
|
572
|
+
'jsonpath_expr_cache_max_size': cls._MAX_EXPR_CACHE,
|
|
573
|
+
'jsonpath_cache_size': cls._jsonpath_cache.size(),
|
|
574
|
+
'jsonpath_cache_max_size': cls._jsonpath_cache.max_size
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def redact(data: Any, ignored_keys: List[str] = None, include_keys: List[str] = None) -> Any:
|
|
579
|
+
"""
|
|
580
|
+
Redact PII and secrets from any data type using built-in detectors only.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
data: Input data to redact (dict, list, str, int, float, bool, etc.)
|
|
584
|
+
ignored_keys: Optional list of dot-notation paths to ignore during redaction
|
|
585
|
+
include_keys: Optional list of dot-notation paths to redact (all others ignored)
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Data with PII and secrets replaced by placeholders, preserving original types
|
|
589
|
+
|
|
590
|
+
Raises:
|
|
591
|
+
ValueError: If both ignored_keys and include_keys are provided
|
|
592
|
+
|
|
593
|
+
This function is:
|
|
594
|
+
- Fully offline (no network calls)
|
|
595
|
+
- Deterministic (same input → same output)
|
|
596
|
+
- Idempotent (redact(redact(x)) == redact(x))
|
|
597
|
+
- Uses only library defaults (no custom patterns)
|
|
598
|
+
"""
|
|
599
|
+
return RedactionEngine.redact(data, ignored_keys, include_keys)
|
modules/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# UI modules for user interaction
|
modules/ui/classes.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
from mcpower_shared.mcp_types import UserDecision
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DialogOptions:
|
|
10
|
+
"""Options for controlling dialog button visibility"""
|
|
11
|
+
show_always_allow: bool = False
|
|
12
|
+
show_always_block: bool = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ConfirmationRequest:
|
|
17
|
+
"""Request for user confirmation with all necessary context"""
|
|
18
|
+
is_request: bool # Which validation stage
|
|
19
|
+
tool_name: str # Tool being called
|
|
20
|
+
policy_reasons: List[str] # Security policy reasons
|
|
21
|
+
content_data: Dict[str, Any] # Arguments or response data
|
|
22
|
+
severity: str # Security severity level
|
|
23
|
+
event_id: str # Unique event identifier
|
|
24
|
+
operation_type: str # Type of MCP operation (tool, resource, etc.)
|
|
25
|
+
server_name: str # Proxied server name
|
|
26
|
+
timeout_seconds: int = 60 # Confirmation timeout
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ConfirmationResponse:
|
|
31
|
+
"""User's confirmation decision with metadata"""
|
|
32
|
+
user_decision: UserDecision # User decision enum
|
|
33
|
+
timestamp: datetime # Decision timestamp
|
|
34
|
+
event_id: str # Matching event identifier
|
|
35
|
+
direction: str # "request" or "response"
|
|
36
|
+
call_type: Optional[str] = None # From inspect decision ("read", "write")
|
|
37
|
+
timed_out: bool = False # Whether decision timed out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UserConfirmationError(Exception):
|
|
41
|
+
"""Raised when a user denies confirmation or confirmation fails"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, event_id: str, is_request: bool, tool_name: str):
|
|
44
|
+
self.message = message
|
|
45
|
+
self.event_id = event_id
|
|
46
|
+
self.is_request = is_request
|
|
47
|
+
self.tool_name = tool_name
|
|
48
|
+
super().__init__(message)
|