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.
Files changed (43) hide show
  1. main.py +112 -0
  2. mcpower_proxy-0.0.58.dist-info/METADATA +250 -0
  3. mcpower_proxy-0.0.58.dist-info/RECORD +43 -0
  4. mcpower_proxy-0.0.58.dist-info/WHEEL +5 -0
  5. mcpower_proxy-0.0.58.dist-info/entry_points.txt +2 -0
  6. mcpower_proxy-0.0.58.dist-info/licenses/LICENSE +201 -0
  7. mcpower_proxy-0.0.58.dist-info/top_level.txt +3 -0
  8. modules/__init__.py +1 -0
  9. modules/apis/__init__.py +1 -0
  10. modules/apis/security_policy.py +322 -0
  11. modules/logs/__init__.py +1 -0
  12. modules/logs/audit_trail.py +162 -0
  13. modules/logs/logger.py +128 -0
  14. modules/redaction/__init__.py +13 -0
  15. modules/redaction/constants.py +38 -0
  16. modules/redaction/gitleaks_rules.py +1268 -0
  17. modules/redaction/pii_rules.py +271 -0
  18. modules/redaction/redactor.py +599 -0
  19. modules/ui/__init__.py +1 -0
  20. modules/ui/classes.py +48 -0
  21. modules/ui/confirmation.py +200 -0
  22. modules/ui/simple_dialog.py +104 -0
  23. modules/ui/xdialog/__init__.py +249 -0
  24. modules/ui/xdialog/constants.py +13 -0
  25. modules/ui/xdialog/mac_dialogs.py +190 -0
  26. modules/ui/xdialog/tk_dialogs.py +78 -0
  27. modules/ui/xdialog/windows_custom_dialog.py +426 -0
  28. modules/ui/xdialog/windows_dialogs.py +250 -0
  29. modules/ui/xdialog/windows_structs.py +183 -0
  30. modules/ui/xdialog/yad_dialogs.py +236 -0
  31. modules/ui/xdialog/zenity_dialogs.py +156 -0
  32. modules/utils/__init__.py +1 -0
  33. modules/utils/cli.py +46 -0
  34. modules/utils/config.py +193 -0
  35. modules/utils/copy.py +36 -0
  36. modules/utils/ids.py +160 -0
  37. modules/utils/json.py +120 -0
  38. modules/utils/mcp_configs.py +48 -0
  39. wrapper/__init__.py +1 -0
  40. wrapper/__version__.py +6 -0
  41. wrapper/middleware.py +750 -0
  42. wrapper/schema.py +227 -0
  43. 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)