contextos-memory-runtime 1.0.0rc2__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 (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,88 @@
1
+ """Explicit synchronization of ephemeral retrieval indexes with SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from contextos.core.enums import MemoryStatus
8
+ from contextos.core.models import Memory, MemoryFilters
9
+ from contextos.core.protocols import EmbeddingService, LexicalIndex, MemoryRepository, VectorStore
10
+
11
+
12
+ INDEXED_STATUSES = (
13
+ MemoryStatus.ACTIVE,
14
+ MemoryStatus.HISTORICAL,
15
+ MemoryStatus.SUPERSEDED,
16
+ MemoryStatus.CONTRADICTED,
17
+ MemoryStatus.EXPIRED,
18
+ )
19
+
20
+
21
+ class RetrievalIndexSynchronizer:
22
+ """Rebuild both indexes when the persisted memory fingerprint changes."""
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ memory_repo: MemoryRepository,
28
+ lexical_index: LexicalIndex,
29
+ vector_store: VectorStore,
30
+ embedding_service: EmbeddingService,
31
+ ) -> None:
32
+ self._memory_repo = memory_repo
33
+ self._lexical_index = lexical_index
34
+ self._vector_store = vector_store
35
+ self._embedding_service = embedding_service
36
+ self._fingerprint: tuple[tuple[str, int, str, str], ...] | None = None
37
+ self._lock = asyncio.Lock()
38
+
39
+ async def ensure_current(self, *, force: bool = False) -> bool:
40
+ """Synchronize indexes; return whether a rebuild occurred."""
41
+ async with self._lock:
42
+ memories = await self._load_indexable_memories()
43
+ fingerprint = tuple(
44
+ sorted(
45
+ (str(memory.id), memory.version, memory.content_hash, memory.status.value)
46
+ for memory in memories
47
+ )
48
+ )
49
+ if not force and fingerprint == self._fingerprint:
50
+ return False
51
+ await self._rebuild(memories)
52
+ self._fingerprint = fingerprint
53
+ return True
54
+
55
+ async def _load_indexable_memories(self) -> list[Memory]:
56
+ memories: list[Memory] = []
57
+ for status in INDEXED_STATUSES:
58
+ offset = 0
59
+ while True:
60
+ page = await self._memory_repo.list(
61
+ MemoryFilters(status=status, limit=500, offset=offset)
62
+ )
63
+ memories.extend(page)
64
+ if len(page) < 500:
65
+ break
66
+ offset += len(page)
67
+ return memories
68
+
69
+ async def _rebuild(self, memories: list[Memory]) -> None:
70
+ documents = {str(memory.id): memory.content for memory in memories}
71
+ ids = [str(memory.id) for memory in memories]
72
+ vectors = (
73
+ await self._embedding_service.embed([memory.content for memory in memories])
74
+ if memories
75
+ else []
76
+ )
77
+ metadata = [
78
+ {
79
+ "status": memory.status.value,
80
+ "type": memory.type.value,
81
+ "source_type": memory.source_type,
82
+ }
83
+ for memory in memories
84
+ ]
85
+ # Generate and validate the more failure-prone dense corpus before
86
+ # replacing either live index.
87
+ await self._vector_store.rebuild(ids, vectors, metadata)
88
+ await self._lexical_index.rebuild(documents)
@@ -0,0 +1,302 @@
1
+ """Deterministic Model Router for ContextOS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ from contextos.core.enums import RoutingPolicy, TokenMeasurementSource
10
+ from contextos.core.exceptions import (
11
+ ContextWindowExceededError,
12
+ ModelUnavailableError,
13
+ ProviderUnavailableError,
14
+ RoutingFailureError,
15
+ )
16
+ from contextos.core.models import (
17
+ ModelCapabilities,
18
+ ModelRequest,
19
+ RouteDecision,
20
+ )
21
+ from contextos.core.protocols import ModelProvider
22
+ from contextos.services.token_counter import TokenCounter, get_token_counter_for_model
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class DeterministicModelRouter:
28
+ """Selects provider and model deterministically based on policy and constraints."""
29
+
30
+ def __init__(
31
+ self,
32
+ default_provider_id: str = "fake",
33
+ default_model_id: str = "fake-default",
34
+ default_policy: RoutingPolicy = RoutingPolicy.LOCAL_FIRST,
35
+ ) -> None:
36
+ self._default_provider_id = default_provider_id
37
+ self._default_model_id = default_model_id
38
+ self._default_policy = default_policy
39
+
40
+ async def route(
41
+ self,
42
+ request: ModelRequest,
43
+ providers: dict[str, ModelProvider],
44
+ policy: RoutingPolicy | None = None,
45
+ ) -> RouteDecision:
46
+ """Route request to the most appropriate healthy provider and model."""
47
+ start_time = time.perf_counter()
48
+ chosen_policy = policy or request.routing_policy or self._default_policy
49
+
50
+ if not providers:
51
+ raise RoutingFailureError(chosen_policy.value, "No providers registered in system")
52
+
53
+ candidates_evaluated: list[str] = []
54
+
55
+ # -------------------------------------------------------------------
56
+ # Policy: EXPLICIT
57
+ # -------------------------------------------------------------------
58
+ if chosen_policy == RoutingPolicy.EXPLICIT or (request.provider and chosen_policy != RoutingPolicy.FIXED_DEFAULT):
59
+ prov_id = request.provider or self._default_provider_id
60
+ if prov_id not in providers:
61
+ raise ProviderUnavailableError(prov_id, f"Provider '{prov_id}' not found in registry")
62
+
63
+ provider = providers[prov_id]
64
+ is_healthy = await provider.health()
65
+ if not is_healthy:
66
+ raise ProviderUnavailableError(prov_id, f"Provider '{prov_id}' is unhealthy")
67
+
68
+ models = await provider.list_models()
69
+ target_model_id = request.model or (models[0].model_id if models else self._default_model_id)
70
+ candidates_evaluated.append(f"{prov_id}/{target_model_id}")
71
+
72
+ matched_model = next((m for m in models if m.model_id == target_model_id), None)
73
+ if not matched_model:
74
+ raise ModelUnavailableError(target_model_id, prov_id)
75
+
76
+ self._validate_context_window(request, matched_model)
77
+
78
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
79
+ return RouteDecision(
80
+ policy=chosen_policy,
81
+ reason="Explicit provider/model selection requested and verified",
82
+ candidates_evaluated=candidates_evaluated,
83
+ selected_provider=prov_id,
84
+ selected_model=target_model_id,
85
+ fallback_used=False,
86
+ routing_latency_ms=elapsed_ms,
87
+ )
88
+
89
+ # -------------------------------------------------------------------
90
+ # Policy: FIXED_DEFAULT
91
+ # -------------------------------------------------------------------
92
+ if chosen_policy == RoutingPolicy.FIXED_DEFAULT:
93
+ prov_id = self._default_provider_id
94
+ if prov_id not in providers:
95
+ raise ProviderUnavailableError(prov_id, "Default provider not registered")
96
+
97
+ provider = providers[prov_id]
98
+ if not await provider.health():
99
+ raise ProviderUnavailableError(prov_id, "Default provider is unhealthy")
100
+
101
+ models = await provider.list_models()
102
+ matched_model = next((m for m in models if m.model_id == self._default_model_id), None)
103
+ if not matched_model and models:
104
+ matched_model = models[0]
105
+ if not matched_model:
106
+ raise ModelUnavailableError(self._default_model_id, prov_id)
107
+
108
+ candidates_evaluated.append(f"{prov_id}/{matched_model.model_id}")
109
+ self._validate_context_window(request, matched_model)
110
+
111
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
112
+ return RouteDecision(
113
+ policy=chosen_policy,
114
+ reason="Fixed default policy selected configured default model",
115
+ candidates_evaluated=candidates_evaluated,
116
+ selected_provider=prov_id,
117
+ selected_model=matched_model.model_id,
118
+ fallback_used=False,
119
+ routing_latency_ms=elapsed_ms,
120
+ )
121
+
122
+ # -------------------------------------------------------------------
123
+ # Policy: LOCAL_FIRST
124
+ # -------------------------------------------------------------------
125
+ if chosen_policy == RoutingPolicy.LOCAL_FIRST:
126
+ local_providers = [p for p in providers.values() if p.is_local]
127
+ selected_local: tuple[ModelProvider, ModelCapabilities] | None = None
128
+ initial_provider_tried: str | None = None
129
+
130
+ for prov in local_providers:
131
+ initial_provider_tried = prov.provider_id
132
+ try:
133
+ if not await prov.health():
134
+ candidates_evaluated.append(f"{prov.provider_id} (unhealthy)")
135
+ continue
136
+ prov_models = await prov.list_models()
137
+ except Exception:
138
+ candidates_evaluated.append(f"{prov.provider_id} (health check failed)")
139
+ continue
140
+
141
+ for model in prov_models:
142
+ candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
143
+ if self._fits_context_and_capabilities(request, model):
144
+ selected_local = (prov, model)
145
+ break
146
+ if selected_local:
147
+ break
148
+
149
+ if selected_local:
150
+ prov, model = selected_local
151
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
152
+ return RouteDecision(
153
+ policy=chosen_policy,
154
+ reason="Local-first policy found healthy local provider and model fitting context window",
155
+ candidates_evaluated=candidates_evaluated,
156
+ selected_provider=prov.provider_id,
157
+ selected_model=model.model_id,
158
+ fallback_used=False,
159
+ routing_latency_ms=elapsed_ms,
160
+ )
161
+
162
+ # Local provider not found or unhealthy
163
+ if not request.allow_fallback:
164
+ raise ProviderUnavailableError(
165
+ initial_provider_tried or "local",
166
+ "Local providers unavailable and fallback not allowed by request policy",
167
+ )
168
+
169
+ # Fallback to remote provider
170
+ remote_providers = [p for p in providers.values() if not p.is_local]
171
+ selected_remote: tuple[ModelProvider, ModelCapabilities] | None = None
172
+ for prov in remote_providers:
173
+ if not await prov.health():
174
+ candidates_evaluated.append(f"{prov.provider_id} (unhealthy)")
175
+ continue
176
+ prov_models = await prov.list_models()
177
+ for model in prov_models:
178
+ candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
179
+ if self._fits_context_and_capabilities(request, model):
180
+ selected_remote = (prov, model)
181
+ break
182
+ if selected_remote:
183
+ break
184
+
185
+ if selected_remote:
186
+ prov, model = selected_remote
187
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
188
+ return RouteDecision(
189
+ policy=chosen_policy,
190
+ reason="Local provider unavailable; successfully fell back to remote provider",
191
+ candidates_evaluated=candidates_evaluated,
192
+ selected_provider=prov.provider_id,
193
+ selected_model=model.model_id,
194
+ fallback_used=True,
195
+ initial_provider=initial_provider_tried,
196
+ fallback_reason="Local provider unavailable or failed health check",
197
+ routing_latency_ms=elapsed_ms,
198
+ )
199
+
200
+ raise RoutingFailureError(
201
+ chosen_policy.value,
202
+ "Neither local nor remote fallback providers could satisfy the request",
203
+ )
204
+
205
+ # -------------------------------------------------------------------
206
+ # Policy: CAPABILITY_AWARE
207
+ # -------------------------------------------------------------------
208
+ if chosen_policy == RoutingPolicy.CAPABILITY_AWARE:
209
+ all_candidates: list[tuple[ModelProvider, ModelCapabilities]] = []
210
+ for prov in providers.values():
211
+ if not await prov.health():
212
+ continue
213
+ models = await prov.list_models()
214
+ for model in models:
215
+ candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
216
+ if self._fits_context_and_capabilities(request, model):
217
+ all_candidates.append((prov, model))
218
+
219
+ if not all_candidates:
220
+ raise RoutingFailureError(
221
+ chosen_policy.value,
222
+ f"No model satisfied required capabilities: {request.required_capabilities}",
223
+ )
224
+
225
+ # Prefer local if available, then by largest context window
226
+ all_candidates.sort(key=lambda item: (not item[0].is_local, -item[1].context_window))
227
+ best_prov, best_model = all_candidates[0]
228
+
229
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
230
+ return RouteDecision(
231
+ policy=chosen_policy,
232
+ reason="Capability-aware policy matched required capabilities and context fit",
233
+ candidates_evaluated=candidates_evaluated,
234
+ selected_provider=best_prov.provider_id,
235
+ selected_model=best_model.model_id,
236
+ fallback_used=False,
237
+ routing_latency_ms=elapsed_ms,
238
+ )
239
+
240
+ raise RoutingFailureError(str(chosen_policy), f"Unsupported routing policy '{chosen_policy}'")
241
+
242
+ def _compute_safety_margin(self, counter: TokenCounter) -> int:
243
+ """Conservative buffer for chat framing (roles, formatting) and approximation variance."""
244
+ base_framing = 16
245
+ if counter.measurement_source == TokenMeasurementSource.APPROXIMATED:
246
+ return base_framing + 32
247
+ return base_framing
248
+
249
+ def _fits_context_and_capabilities(
250
+ self, request: ModelRequest, model: ModelCapabilities
251
+ ) -> bool:
252
+ """Check capability constraints and context window fit."""
253
+ if not model.enabled:
254
+ return False
255
+
256
+ # Capability checks
257
+ if "tools" in request.required_capabilities and not model.supports_tools:
258
+ return False
259
+ if "json" in request.required_capabilities and not model.supports_json:
260
+ return False
261
+ if "vision" in request.required_capabilities and not model.supports_vision:
262
+ return False
263
+
264
+ # Context window fit with safety margin for framing/approximation
265
+ context_text = request.compiled_context.context_text if request.compiled_context else ""
266
+ counter = get_token_counter_for_model(model.model_id, model.tokenizer_family)
267
+ prompt_tokens = counter.count(request.user_prompt)
268
+ if request.system_prompt:
269
+ prompt_tokens += counter.count(request.system_prompt)
270
+ context_tokens = counter.count(context_text) if context_text else 0
271
+ reserved_output = request.max_output_tokens or 1024
272
+ margin = self._compute_safety_margin(counter)
273
+
274
+ total_needed = prompt_tokens + context_tokens + reserved_output + margin
275
+ return total_needed <= model.context_window
276
+
277
+ def _validate_context_window(
278
+ self, request: ModelRequest, model: ModelCapabilities
279
+ ) -> None:
280
+ """Ensure total needed tokens fit within the model context window.
281
+
282
+ Raises ContextWindowExceededError if it does not fit.
283
+ """
284
+ context_text = request.compiled_context.context_text if request.compiled_context else ""
285
+ counter = get_token_counter_for_model(model.model_id, model.tokenizer_family)
286
+ prompt_tokens = counter.count(request.user_prompt)
287
+ if request.system_prompt:
288
+ prompt_tokens += counter.count(request.system_prompt)
289
+ context_tokens = counter.count(context_text) if context_text else 0
290
+ reserved_output = request.max_output_tokens or 1024
291
+ margin = self._compute_safety_margin(counter)
292
+
293
+ total_needed = prompt_tokens + context_tokens + reserved_output + margin
294
+ if total_needed > model.context_window:
295
+ raise ContextWindowExceededError(
296
+ model_id=model.model_id,
297
+ required_tokens=total_needed,
298
+ context_window=model.context_window,
299
+ prompt_tokens=prompt_tokens,
300
+ compiled_context_tokens=context_tokens,
301
+ reserved_output_tokens=reserved_output,
302
+ )
@@ -0,0 +1,207 @@
1
+ """Local structured credential detection and overlap-safe redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from contextos.core.enums import SecretType
10
+ from contextos.core.models import ScanResult, SecretMatch
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SecretPattern:
15
+ secret_type: SecretType
16
+ pattern: re.Pattern[str]
17
+ confidence: float
18
+ detector: str
19
+ secret_group: int = 0
20
+
21
+
22
+ def _pattern(
23
+ secret_type: SecretType,
24
+ expression: str,
25
+ confidence: float,
26
+ detector: str,
27
+ *,
28
+ flags: int = 0,
29
+ secret_group: int = 0,
30
+ ) -> SecretPattern:
31
+ return SecretPattern(
32
+ secret_type, re.compile(expression, flags), confidence, detector, secret_group
33
+ )
34
+
35
+
36
+ SECRET_PATTERNS: list[SecretPattern] = [
37
+ _pattern(SecretType.PRIVATE_KEY,
38
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?"
39
+ r"-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
40
+ 0.99, "private-key-block", flags=re.MULTILINE),
41
+ _pattern(SecretType.AUTHORIZATION_HEADER,
42
+ r"\bauthorization\s*:\s*(?:bearer|basic)\s+([^\s,;]+)",
43
+ 0.99, "authorization-header", flags=re.I, secret_group=1),
44
+ _pattern(SecretType.AWS_ACCESS_KEY, r"(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])",
45
+ 0.98, "aws-access-key"),
46
+ _pattern(SecretType.GITHUB_TOKEN, r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}",
47
+ 0.98, "github-token"),
48
+ _pattern(SecretType.OPENAI_API_KEY,
49
+ r"sk-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}",
50
+ 0.99, "openai-legacy-key"),
51
+ _pattern(SecretType.OPENAI_API_KEY, r"sk-proj-[A-Za-z0-9_-]{40,}",
52
+ 0.98, "openai-project-key"),
53
+ _pattern(SecretType.ANTHROPIC_API_KEY, r"sk-ant-[A-Za-z0-9_-]{40,}",
54
+ 0.98, "anthropic-key"),
55
+ _pattern(SecretType.GOOGLE_API_KEY, r"AIza[0-9A-Za-z_-]{35}",
56
+ 0.96, "google-api-key"),
57
+ _pattern(SecretType.SLACK_TOKEN,
58
+ r"xox[baprs]-[0-9a-zA-Z]{10,}(?:-[0-9a-zA-Z]{10,})*",
59
+ 0.96, "slack-token"),
60
+ _pattern(SecretType.STRIPE_KEY, r"(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,}",
61
+ 0.98, "stripe-key"),
62
+ _pattern(SecretType.JWT,
63
+ r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
64
+ 0.96, "jwt-token"),
65
+ _pattern(SecretType.CONNECTION_STRING,
66
+ r"(?:mongodb|postgres|postgresql|mysql|redis|amqp)(?:\+[a-z]+)?://"
67
+ r"[^\s:/]+:[^\s@]+@[^\s]+",
68
+ 0.96, "credential-connection-string", flags=re.I),
69
+ _pattern(SecretType.CONNECTION_STRING,
70
+ r"(?:https?|ftp)://[^\s:/]+:[^\s@]+@[^\s/]+(?:/[^\s]*)?",
71
+ 0.94, "credential-url-userinfo", flags=re.I),
72
+ _pattern(SecretType.BEARER_TOKEN, r"\bbearer\s+([A-Za-z0-9._~+/=-]{12,})",
73
+ 0.94, "bearer-token", flags=re.I, secret_group=1),
74
+ _pattern(SecretType.GENERIC_API_KEY,
75
+ r"\b(?:api[_ -]?key|apikey|api[_ -]?secret)\b\s*(?:is|=|:)\s*"
76
+ r"['\"]?([A-Za-z0-9_./+=-]{11,}[A-Za-z0-9_+=-])['\"]?",
77
+ 0.92, "credential-assignment", flags=re.I, secret_group=1),
78
+ _pattern(SecretType.ACCESS_TOKEN,
79
+ r"\b(?:access[_ -]?token|refresh[_ -]?token)\b\s*(?:is|=|:)\s*"
80
+ r"['\"]?([A-Za-z0-9_./+=-]{12,})['\"]?",
81
+ 0.93, "token-assignment", flags=re.I, secret_group=1),
82
+ _pattern(SecretType.ACCESS_TOKEN,
83
+ r"[?&](?:x-amz-signature|x-goog-signature|signature|sig)="
84
+ r"([^&#\s]{16,})",
85
+ 0.95, "signed-url-signature", flags=re.I, secret_group=1),
86
+ _pattern(SecretType.SESSION_COOKIE,
87
+ r"\b(?:session(?:[_ -]?(?:id|token))?|cookie)\b\s*(?:is|=|:)\s*"
88
+ r"['\"]?([A-Za-z0-9_./+=-]{12,})['\"]?",
89
+ 0.91, "session-cookie", flags=re.I, secret_group=1),
90
+ _pattern(SecretType.PASSWORD,
91
+ r"\b(?:password|passwd|pwd|pass)\b\s*(?:is|=|:)\s*"
92
+ r"['\"]?([^\s'\"]{6,})['\"]?",
93
+ 0.91, "password-assignment", flags=re.I, secret_group=1),
94
+ _pattern(SecretType.OTP,
95
+ r"\b(?:otp|one[ -]?time (?:password|code)|verification code|pin)\b"
96
+ r"\s*(?:is|=|:)\s*([0-9]{4,8})\b",
97
+ 0.95, "contextual-otp", flags=re.I, secret_group=1),
98
+ _pattern(SecretType.AWS_SECRET_KEY,
99
+ r"\baws[_ -]?secret(?:[_ -]?access)?[_ -]?key\b\s*(?:is|=|:)\s*"
100
+ r"['\"]?([A-Za-z0-9/+=]{40})['\"]?",
101
+ 0.96, "aws-secret-key", flags=re.I, secret_group=1),
102
+ ]
103
+
104
+
105
+ def _shannon_entropy(value: str) -> float:
106
+ if not value:
107
+ return 0.0
108
+ frequencies: dict[str, int] = {}
109
+ for character in value:
110
+ frequencies[character] = frequencies.get(character, 0) + 1
111
+ length = len(value)
112
+ return -sum(
113
+ (count / length) * math.log2(count / length) for count in frequencies.values()
114
+ )
115
+
116
+
117
+ def _find_high_entropy_strings(
118
+ text: str,
119
+ min_length: int = 20,
120
+ max_length: int = 200,
121
+ entropy_threshold: float = 4.5,
122
+ ) -> list[SecretMatch]:
123
+ """Optional advisory detector; disabled by default to control false positives."""
124
+ matches: list[SecretMatch] = []
125
+ token_pattern = re.compile(
126
+ r"[A-Za-z0-9+/=_-]{" + str(min_length) + "," + str(max_length) + "}"
127
+ )
128
+ for match in token_pattern.finditer(text):
129
+ entropy = _shannon_entropy(match.group())
130
+ if entropy >= entropy_threshold:
131
+ matches.append(SecretMatch(
132
+ secret_type=SecretType.HIGH_ENTROPY,
133
+ start=match.start(),
134
+ end=match.end(),
135
+ matched_text="[REDACTED:high_entropy]",
136
+ confidence=min(0.89, (entropy - entropy_threshold) / 2.0 + 0.5),
137
+ ))
138
+ return matches
139
+
140
+
141
+ class PatternSecretScanner:
142
+ """Replaceable local scanner implementing the existing SecretScanner protocol."""
143
+
144
+ def __init__(
145
+ self,
146
+ *,
147
+ min_confidence: float = 0.5,
148
+ enable_entropy: bool = False,
149
+ entropy_threshold: float = 4.5,
150
+ ) -> None:
151
+ self._min_confidence = min_confidence
152
+ self._enable_entropy = enable_entropy
153
+ self._entropy_threshold = entropy_threshold
154
+
155
+ def scan(self, text: str) -> ScanResult:
156
+ if not text:
157
+ return ScanResult(scanned_length=0)
158
+ matches: list[SecretMatch] = []
159
+ for secret_pattern in SECRET_PATTERNS:
160
+ if secret_pattern.confidence < self._min_confidence:
161
+ continue
162
+ for match in secret_pattern.pattern.finditer(text):
163
+ start, end = match.span(secret_pattern.secret_group)
164
+ matches.append(SecretMatch(
165
+ secret_type=secret_pattern.secret_type,
166
+ start=start,
167
+ end=end,
168
+ matched_text=f"[REDACTED:{secret_pattern.secret_type.value}]",
169
+ confidence=secret_pattern.confidence,
170
+ ))
171
+ if self._enable_entropy:
172
+ matches.extend(_find_high_entropy_strings(
173
+ text, entropy_threshold=self._entropy_threshold
174
+ ))
175
+ matches = self._deduplicate_overlapping(matches)
176
+ return ScanResult(
177
+ has_secrets=bool(matches), matches=matches, scanned_length=len(text)
178
+ )
179
+
180
+ def redact(self, text: str) -> tuple[str, ScanResult]:
181
+ result = self.scan(text)
182
+ redacted = text
183
+ for match in sorted(result.matches, key=lambda item: item.start, reverse=True):
184
+ redacted = (
185
+ redacted[:match.start]
186
+ + f"[REDACTED:{match.secret_type.value}]"
187
+ + redacted[match.end:]
188
+ )
189
+ return redacted, result
190
+
191
+ @staticmethod
192
+ def _deduplicate_overlapping(matches: list[SecretMatch]) -> list[SecretMatch]:
193
+ selected: list[SecretMatch] = []
194
+ for current in sorted(matches, key=lambda item: (item.start, -item.confidence, -item.end)):
195
+ if selected and current.start < selected[-1].end:
196
+ previous = selected[-1]
197
+ winner = current if current.confidence > previous.confidence else previous
198
+ selected[-1] = SecretMatch(
199
+ secret_type=winner.secret_type,
200
+ start=min(previous.start, current.start),
201
+ end=max(previous.end, current.end),
202
+ matched_text=f"[REDACTED:{winner.secret_type.value}]",
203
+ confidence=max(previous.confidence, current.confidence),
204
+ )
205
+ continue
206
+ selected.append(current)
207
+ return selected