atlas-chat 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 (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,242 @@
1
+ """Unified logging & OpenTelemetry setup.
2
+
3
+ Provides:
4
+ - Structured JSON logging with optional trace/span identifiers
5
+ - Environment or config-derived log level
6
+ - Standard file output (project_root/logs/app.jsonl) with APP_LOG_DIR override
7
+ - FastAPI & HTTPX instrumentation hooks
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Optional
18
+
19
+ from opentelemetry import trace
20
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
21
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
22
+ from opentelemetry.instrumentation.logging import LoggingInstrumentor
23
+ from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource
24
+ from opentelemetry.sdk.trace import TracerProvider
25
+
26
+
27
+ class JSONFormatter(logging.Formatter):
28
+ """Format log records as JSON lines."""
29
+
30
+ def format(self, record: logging.LogRecord) -> str: # noqa: D401
31
+ span = trace.get_current_span()
32
+ trace_id = span_id = None
33
+ if span and span.is_recording():
34
+ sc = span.get_span_context()
35
+ if sc.is_valid:
36
+ trace_id = f"{sc.trace_id:032x}"
37
+ span_id = f"{sc.span_id:016x}"
38
+
39
+ entry: Dict[str, Any] = {
40
+ "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
41
+ "level": record.levelname,
42
+ "logger": record.name,
43
+ "message": record.getMessage(),
44
+ "module": record.module,
45
+ "function": record.funcName,
46
+ "line": record.lineno,
47
+ "process_id": os.getpid(),
48
+ "thread_id": record.thread,
49
+ "thread_name": record.threadName,
50
+ }
51
+ if trace_id:
52
+ entry["trace_id"] = trace_id
53
+ if span_id:
54
+ entry["span_id"] = span_id
55
+ if record.exc_info:
56
+ entry["exception"] = self.formatException(record.exc_info)
57
+
58
+ excluded = {
59
+ "name","msg","args","levelname","levelno","pathname","filename","module","lineno",
60
+ "funcName","created","msecs","relativeCreated","thread","threadName","processName","process",
61
+ "exc_info","exc_text","stack_info","getMessage"
62
+ }
63
+ for k, v in record.__dict__.items():
64
+ if k not in excluded:
65
+ entry[f"extra_{k}"] = v
66
+ return json.dumps(entry, default=str)
67
+
68
+
69
+ class OpenTelemetryConfig:
70
+ """Configure OpenTelemetry + structured logging."""
71
+
72
+ def __init__(self, service_name: str = "atlas-ui-3-backend", service_version: str = "1.0.0") -> None:
73
+ self.service_name = service_name
74
+ self.service_version = service_version
75
+ self.is_development = self._is_development()
76
+ self.log_level = self._get_log_level()
77
+ # Resolve logs directory robustly: use config manager
78
+ self.logs_dir = self._get_logs_dir()
79
+ self.log_file = self.logs_dir / "app.jsonl"
80
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
81
+ self._setup_telemetry()
82
+ self._setup_logging()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Internals
86
+ # ------------------------------------------------------------------
87
+ def _get_logs_dir(self) -> Path:
88
+ """Get logs directory from config manager or default to project_root/logs."""
89
+ try:
90
+ from atlas.modules.config import config_manager
91
+ if config_manager.app_settings.app_log_dir:
92
+ return Path(config_manager.app_settings.app_log_dir)
93
+ except Exception:
94
+ # Config manager may not be initialized during early startup or tests.
95
+ # Fall back to default logs directory without logging (avoid circular deps).
96
+ pass
97
+ # Fallback: project_root/logs
98
+ project_root = Path(__file__).resolve().parents[2]
99
+ return project_root / "logs"
100
+
101
+ def _is_development(self) -> bool:
102
+ try:
103
+ from atlas.modules.config import config_manager
104
+ settings = config_manager.app_settings
105
+ return (
106
+ settings.debug_mode
107
+ or settings.environment.lower() in {"dev", "development"}
108
+ )
109
+ except Exception:
110
+ # Fallback to environment variables if config not available
111
+ return (
112
+ os.getenv("DEBUG_MODE", "false").lower() == "true"
113
+ or os.getenv("ENVIRONMENT", "production").lower() in {"dev", "development"}
114
+ )
115
+
116
+ def _get_log_level(self) -> int:
117
+ try:
118
+ from atlas.modules.config import config_manager
119
+ level_name = config_manager.app_settings.log_level.upper()
120
+ except Exception:
121
+ # Fallback to environment variable if config not available
122
+ level_name = os.getenv("LOG_LEVEL", "INFO").upper()
123
+ level = getattr(logging, level_name, None)
124
+ return level if isinstance(level, int) else logging.INFO
125
+
126
+ def _setup_telemetry(self) -> None:
127
+ resource = Resource.create(
128
+ {
129
+ SERVICE_NAME: self.service_name,
130
+ SERVICE_VERSION: self.service_version,
131
+ "environment": "development" if self.is_development else "production",
132
+ }
133
+ )
134
+ trace.set_tracer_provider(TracerProvider(resource=resource))
135
+
136
+ def _setup_logging(self) -> None:
137
+ root = logging.getLogger()
138
+ for h in root.handlers[:]:
139
+ root.removeHandler(h)
140
+
141
+ json_formatter = JSONFormatter()
142
+ file_handler = logging.FileHandler(self.log_file, encoding="utf-8")
143
+ file_handler.setFormatter(json_formatter)
144
+ file_handler.setLevel(self.log_level)
145
+ root.addHandler(file_handler)
146
+ root.setLevel(self.log_level)
147
+
148
+ # Reduce noise from third-party libraries at INFO.
149
+ # We still want their warnings/errors, and their debug output remains available
150
+ # when LOG_LEVEL=DEBUG.
151
+ if self.log_level > logging.DEBUG:
152
+ for noisy in (
153
+ "httpx",
154
+ "httpcore",
155
+ "LiteLLM",
156
+ "litellm",
157
+ ):
158
+ logging.getLogger(noisy).setLevel(logging.WARNING)
159
+
160
+ if self.is_development:
161
+ console = logging.StreamHandler()
162
+ console.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
163
+ console.setLevel(logging.WARNING)
164
+ root.addHandler(console)
165
+ for noisy in (
166
+ "httpx",
167
+ "urllib3.connectionpool",
168
+ "auth_utils",
169
+ "message_processor",
170
+ "session",
171
+ "callbacks",
172
+ "utils",
173
+ "banner_client",
174
+ "middleware",
175
+ "mcp_client",
176
+ ):
177
+ logging.getLogger(noisy).setLevel(logging.DEBUG)
178
+
179
+ LoggingInstrumentor().instrument(set_logging_format=False)
180
+
181
+ # ------------------------------------------------------------------
182
+ # Public helpers
183
+ # ------------------------------------------------------------------
184
+ def instrument_fastapi(self, app) -> None: # noqa: ANN001
185
+ FastAPIInstrumentor.instrument_app(app)
186
+
187
+ def instrument_httpx(self) -> None:
188
+ HTTPXClientInstrumentor().instrument()
189
+
190
+ def get_log_file_path(self) -> Path:
191
+ return self.log_file
192
+
193
+ def read_logs(self, lines: int = 100) -> list[Dict[str, Any]]:
194
+ if not self.log_file.exists():
195
+ return []
196
+ out: list[Dict[str, Any]] = []
197
+ try:
198
+ with self.log_file.open("r", encoding="utf-8") as f:
199
+ data = f.readlines()[-lines:]
200
+ for ln in data:
201
+ ln = ln.strip()
202
+ if not ln:
203
+ continue
204
+ try:
205
+ out.append(json.loads(ln))
206
+ except json.JSONDecodeError:
207
+ continue
208
+ except Exception as e: # noqa: BLE001
209
+ logging.getLogger(__name__).error(f"Error reading logs: {e}")
210
+ return out
211
+
212
+ def get_log_stats(self) -> Dict[str, Any]:
213
+ if not self.log_file.exists():
214
+ return {"file_exists": False, "file_size": 0, "line_count": 0, "last_modified": None}
215
+ try:
216
+ stat = self.log_file.stat()
217
+ with self.log_file.open("r", encoding="utf-8") as f:
218
+ line_count = sum(1 for _ in f)
219
+ return {
220
+ "file_exists": True,
221
+ "file_size": stat.st_size,
222
+ "line_count": line_count,
223
+ "last_modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
224
+ "file_path": str(self.log_file),
225
+ }
226
+ except Exception as e: # noqa: BLE001
227
+ logging.getLogger(__name__).error(f"Error getting log stats: {e}")
228
+ return {"file_exists": True, "error": str(e)}
229
+
230
+
231
+ # Global instance
232
+ otel_config: Optional[OpenTelemetryConfig] = None
233
+
234
+
235
+ def setup_opentelemetry(service_name: str = "atlas-ui-3-backend", service_version: str = "1.0.0") -> OpenTelemetryConfig:
236
+ global otel_config
237
+ otel_config = OpenTelemetryConfig(service_name, service_version)
238
+ return otel_config
239
+
240
+
241
+ def get_otel_config() -> Optional[OpenTelemetryConfig]:
242
+ return otel_config
@@ -0,0 +1,200 @@
1
+ """
2
+ Prompt injection risk heuristics and structured logging.
3
+
4
+ Scope: lightweight, configurable thresholds; used for user input and RAG results.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import logging
12
+ import math
13
+ import re
14
+ from collections import Counter
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _get_thresholds() -> Dict[str, int]:
23
+ """Get prompt injection risk thresholds from config manager."""
24
+ try:
25
+ from atlas.modules.config import config_manager
26
+ settings = config_manager.app_settings
27
+ return {
28
+ "low": settings.pi_threshold_low,
29
+ "medium": settings.pi_threshold_medium,
30
+ "high": settings.pi_threshold_high,
31
+ }
32
+ except Exception:
33
+ # Fallback to defaults if config not available
34
+ return {
35
+ "low": 30,
36
+ "medium": 50,
37
+ "high": 80,
38
+ }
39
+
40
+
41
+ def calculate_prompt_injection_risk(message: str, *, mode: str = "general") -> Dict[str, object]:
42
+ """
43
+ Calculate a heuristic risk score for prompt injection attempts.
44
+
45
+ Returns: { 'score': int, 'risk_level': str, 'triggers': list[str] }
46
+ """
47
+ score = 0
48
+ triggers: List[str] = []
49
+
50
+ msg_lower = (message or "").lower()
51
+
52
+ # 1) Suspicious patterns
53
+ patterns = {
54
+ "override_instructions": (r"ignore\s+(previous|all|everything|above|prior)", 40),
55
+ "disregard": (r"disregard\s+(previous|all|everything|above|prior)", 40),
56
+ "new_instructions": (r"new\s+instructions?\s*:\s*", 35),
57
+ "system_role": (r"\b(system|assistant|user)\s*:\s*", 30),
58
+ "act_as": (r"act\s+as\s+(if\s+)?you\s+(are|were)", 25),
59
+ "pretend": (r"pretend\s+(to\s+be|you\s+are)", 25),
60
+ "role_change": (r"your?\s+(new\s+)?role\s+(is|now)", 30),
61
+ "forget": (r"forget\s+(everything|all|previous)", 35),
62
+ "override": (r"override\s+(previous|default|system)", 35),
63
+ "jailbreak": (r"(jailbreak|developer\s+mode|god\s+mode)", 45),
64
+ }
65
+ for name, (pat, pts) in patterns.items():
66
+ if re.search(pat, msg_lower):
67
+ score += pts
68
+ triggers.append(name)
69
+
70
+ # 2) Encodings/obfuscation
71
+ if _detect_encoding(message):
72
+ score += 30
73
+ triggers.append("encoding_detected")
74
+
75
+ # 3) Statistical anomalies
76
+ # Delimiter density (triple quotes, fences, etc.)
77
+ delimiters = len(re.findall(r"[#*\-_=]{3,}|[\"\"\"''']{3,}", message or ""))
78
+ if delimiters >= 3:
79
+ score += 25
80
+ triggers.append("excessive_delimiters")
81
+ elif delimiters >= 1:
82
+ score += 10
83
+
84
+ # High entropy (possible encoded blob)
85
+ if len(message or "") > 10:
86
+ ent = _calculate_entropy(message)
87
+ if ent > 4.5:
88
+ score += 20
89
+ triggers.append("high_entropy")
90
+
91
+ # Excessive caps
92
+ if len(message or "") > 20:
93
+ caps_ratio = sum(1 for c in (message or "") if c.isupper()) / max(1, len(message or ""))
94
+ if caps_ratio > 0.3:
95
+ score += 15
96
+ triggers.append("excessive_caps")
97
+
98
+ # 4) Context-breaking attempts
99
+ if (message or "").count("\n") > 5 or re.search(r"\s{10,}", message or ""):
100
+ score += 15
101
+ triggers.append("formatting_abuse")
102
+
103
+ if re.search(r"(human|user|assistant):\s*\n", msg_lower):
104
+ score += 25
105
+ triggers.append("fake_conversation")
106
+
107
+ if (len(message or "") > 50) and re.search(r"<[^>]+>.*</[^>]+>|[{}\[\]]", message or ""):
108
+ score += 20
109
+ triggers.append("structured_injection")
110
+
111
+ # 5) Length penalty
112
+ if len(message or "") > 1000:
113
+ score += 15
114
+ triggers.append("excessive_length")
115
+
116
+ # Context-aware normalization (reduce false positives)
117
+ if mode in ("code", "logs"):
118
+ # Code/logs: braces, fences common; soften penalties
119
+ if "excessive_delimiters" in triggers:
120
+ score -= 10
121
+ if "structured_injection" in triggers:
122
+ score -= 10
123
+ score = max(0, score)
124
+
125
+ # Risk buckets - get thresholds from config
126
+ thresholds = _get_thresholds()
127
+ if score >= thresholds["high"]:
128
+ level = "high"
129
+ elif score >= thresholds["medium"]:
130
+ level = "medium"
131
+ elif score >= thresholds["low"]:
132
+ level = "low"
133
+ else:
134
+ level = "minimal"
135
+
136
+ return {"score": int(score), "risk_level": level, "triggers": triggers}
137
+
138
+
139
+ def _detect_encoding(text: str) -> bool:
140
+ clean = re.sub(r"\s", "", text or "")
141
+ # Base64-like
142
+ if len(clean) > 20 and len(clean) % 4 == 0 and re.match(r"^[A-Za-z0-9+/=]+$", clean or ""):
143
+ try:
144
+ base64.b64decode(clean, validate=True)
145
+ return True
146
+ except Exception:
147
+ pass
148
+ # Hex
149
+ if re.match(r"^(0x)?[0-9a-fA-F]+$", clean or "") and len(clean) > 20:
150
+ return True
151
+ # Escape sequences
152
+ if re.search(r"\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}", text or ""):
153
+ return True
154
+ # Zero-width/unusual unicode
155
+ if re.search(r"[\u200B-\u200D\uFEFF\u2060]", text or ""):
156
+ return True
157
+ return False
158
+
159
+
160
+ def _calculate_entropy(text: str) -> float:
161
+ if not text:
162
+ return 0.0
163
+ counts = Counter(text)
164
+ n = len(text)
165
+ ent = 0.0
166
+ for c in counts.values():
167
+ p = c / n
168
+ ent -= p * math.log2(max(p, 1e-12))
169
+ return ent
170
+
171
+
172
+ def log_high_risk_event(*, source: str, user: Optional[str], content: str, score: int, risk_level: str, triggers: List[str], extra: Optional[Dict[str, object]] = None) -> None:
173
+ """Append a JSONL record for medium/high events to logs/security_high_risk.jsonl."""
174
+ try:
175
+ # Only log medium/high
176
+ if risk_level not in ("medium", "high"):
177
+ return
178
+ base_dir = Path(__file__).resolve().parents[2]
179
+ log_path = base_dir / "logs" / "security_high_risk.jsonl"
180
+ log_path.parent.mkdir(parents=True, exist_ok=True)
181
+ record = {
182
+ "ts": datetime.now(timezone.utc).isoformat() + "Z",
183
+ "type": "prompt_risk",
184
+ "source": source,
185
+ "user": user,
186
+ "score": score,
187
+ "risk_level": risk_level,
188
+ "triggers": triggers,
189
+ }
190
+ if extra:
191
+ record.update(extra)
192
+ # include a small snippet only
193
+ snippet = (content or "")
194
+ if len(snippet) > 240:
195
+ snippet = snippet[:240] + "…"
196
+ record["snippet"] = snippet
197
+ with open(log_path, "a", encoding="utf-8") as f:
198
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
199
+ except Exception as e:
200
+ logger.debug("Failed to write high risk log: %s", e)
File without changes
@@ -0,0 +1,64 @@
1
+ """Simple in-memory rate limit middleware.
2
+
3
+ Fixed-window counter per client IP (and optionally per-path) to throttle requests.
4
+ This is a lightweight safeguard suitable for single-process deployments and tests.
5
+
6
+ Configuration is sourced from ConfigManager (AppSettings) with optional env overrides:
7
+ - app_settings.rate_limit_rpm (env: RATE_LIMIT_RPM, default: 600)
8
+ - app_settings.rate_limit_window_seconds (env: RATE_LIMIT_WINDOW_SECONDS, default: 60)
9
+ - app_settings.rate_limit_per_path (env: RATE_LIMIT_PER_PATH, default: false)
10
+ """
11
+
12
+ import time
13
+ import typing as t
14
+
15
+ from fastapi import Request
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.responses import JSONResponse, Response
18
+
19
+ from atlas.modules.config import config_manager
20
+
21
+
22
+ class RateLimitMiddleware(BaseHTTPMiddleware):
23
+ def __init__(self, app) -> None:
24
+ super().__init__(app)
25
+ settings = config_manager.app_settings
26
+ # Pull from centralized config with sane defaults
27
+ self.window_seconds = int(getattr(settings, "rate_limit_window_seconds", 60))
28
+ self.max_requests = int(getattr(settings, "rate_limit_rpm", 600))
29
+ self.per_path = bool(getattr(settings, "rate_limit_per_path", False))
30
+ # state: key -> (window_start_epoch, count)
31
+ self._buckets: dict[str, t.Tuple[int, int]] = {}
32
+
33
+ def _key_for(self, request: Request) -> str:
34
+ client_ip = getattr(request.client, "host", "unknown") if request.client else "unknown"
35
+ if self.per_path:
36
+ return f"{client_ip}:{request.url.path}"
37
+ return client_ip
38
+
39
+ async def dispatch(self, request: Request, call_next) -> Response:
40
+ now = int(time.time())
41
+ key = self._key_for(request)
42
+ win = self.window_seconds
43
+ start, count = self._buckets.get(key, (now, 0))
44
+
45
+ # Move window if expired
46
+ if now - start >= win:
47
+ start, count = now, 0
48
+
49
+ count += 1
50
+ self._buckets[key] = (start, count)
51
+
52
+ if count > self.max_requests:
53
+ retry_after = max(1, win - (now - start))
54
+ return JSONResponse(
55
+ status_code=429,
56
+ content={
57
+ "detail": "Rate limit exceeded. Please try again later.",
58
+ "limit": self.max_requests,
59
+ "window_seconds": self.window_seconds,
60
+ },
61
+ headers={"Retry-After": str(retry_after)},
62
+ )
63
+
64
+ return await call_next(request)
@@ -0,0 +1,51 @@
1
+ """Security headers middleware with ConfigManager-based toggles.
2
+
3
+ Sets common security headers:
4
+ - Content-Security-Policy (CSP)
5
+ - X-Frame-Options (XFO)
6
+ - X-Content-Type-Options: nosniff
7
+ - Referrer-Policy
8
+
9
+ Each header is individually togglable via AppSettings. HSTS is intentionally omitted.
10
+ """
11
+
12
+ from fastapi import Request
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+ from starlette.responses import Response
15
+ from starlette.types import ASGIApp
16
+
17
+ from atlas.modules.config import config_manager
18
+
19
+
20
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
21
+ def __init__(self, app: ASGIApp) -> None:
22
+ super().__init__(app)
23
+ self.settings = config_manager.app_settings
24
+
25
+ async def dispatch(self, request: Request, call_next):
26
+ response: Response = await call_next(request)
27
+
28
+ # X-Content-Type-Options
29
+ if getattr(self.settings, "security_nosniff_enabled", True):
30
+ if "X-Content-Type-Options" not in response.headers:
31
+ response.headers["X-Content-Type-Options"] = "nosniff"
32
+
33
+ # X-Frame-Options
34
+ if getattr(self.settings, "security_xfo_enabled", True):
35
+ xfo_value = getattr(self.settings, "security_xfo_value", "SAMEORIGIN")
36
+ if "X-Frame-Options" not in response.headers:
37
+ response.headers["X-Frame-Options"] = xfo_value
38
+
39
+ # Referrer-Policy
40
+ if getattr(self.settings, "security_referrer_policy_enabled", True):
41
+ ref_value = getattr(self.settings, "security_referrer_policy_value", "no-referrer")
42
+ if "Referrer-Policy" not in response.headers:
43
+ response.headers["Referrer-Policy"] = ref_value
44
+
45
+ # Content-Security-Policy
46
+ if getattr(self.settings, "security_csp_enabled", True):
47
+ csp_value = getattr(self.settings, "security_csp_value", None)
48
+ if csp_value and "Content-Security-Policy" not in response.headers:
49
+ response.headers["Content-Security-Policy"] = csp_value
50
+
51
+ return response
@@ -0,0 +1,37 @@
1
+ """Domain layer - pure business models and logic."""
2
+
3
+ from .errors import (
4
+ AuthenticationError,
5
+ AuthorizationError,
6
+ ConfigurationError,
7
+ DomainError,
8
+ LLMError,
9
+ MessageError,
10
+ SessionError,
11
+ ToolError,
12
+ ValidationError,
13
+ )
14
+ from .messages.models import ConversationHistory, Message, MessageRole, MessageType, ToolCall, ToolResult
15
+ from .sessions.models import Session
16
+
17
+ __all__ = [
18
+ # Errors
19
+ "DomainError",
20
+ "ValidationError",
21
+ "SessionError",
22
+ "MessageError",
23
+ "AuthenticationError",
24
+ "AuthorizationError",
25
+ "ConfigurationError",
26
+ "LLMError",
27
+ "ToolError",
28
+ # Messages
29
+ "Message",
30
+ "MessageRole",
31
+ "MessageType",
32
+ "ToolCall",
33
+ "ToolResult",
34
+ "ConversationHistory",
35
+ # Sessions
36
+ "Session",
37
+ ]
@@ -0,0 +1 @@
1
+ """Domain models for chat operations."""
@@ -0,0 +1,85 @@
1
+ """Data Transfer Objects for chat operations."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+ from uuid import UUID
6
+
7
+
8
+ @dataclass
9
+ class ChatRequest:
10
+ """
11
+ Request DTO for chat operations.
12
+
13
+ Contains all parameters needed for different chat modes (plain, tools, RAG, agent).
14
+ """
15
+ session_id: UUID
16
+ content: str
17
+ model: str
18
+ user_email: Optional[str] = None
19
+ selected_tools: Optional[List[str]] = None
20
+ selected_prompts: Optional[List[str]] = None
21
+ selected_data_sources: Optional[List[str]] = None
22
+ only_rag: bool = False
23
+ tool_choice_required: bool = False
24
+ agent_mode: bool = False
25
+ temperature: float = 0.7
26
+ agent_max_steps: int = 30
27
+ agent_loop_strategy: Optional[str] = None
28
+ files: Optional[Dict[str, Any]] = None
29
+ extra: Dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass
33
+ class ChatResponse:
34
+ """
35
+ Response DTO for chat operations.
36
+
37
+ Contains the result of a chat interaction.
38
+ """
39
+ type: str
40
+ message: str
41
+ metadata: Dict[str, Any] = field(default_factory=dict)
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ """Convert to dictionary format for API response."""
45
+ return {
46
+ "type": self.type,
47
+ "message": self.message,
48
+ **self.metadata
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class LLMMessage:
54
+ """
55
+ Type-safe message format for LLM interactions.
56
+
57
+ Normalizes message structure across different chat modes.
58
+ """
59
+ role: str # "user", "assistant", "system", "tool"
60
+ content: str
61
+ name: Optional[str] = None
62
+ tool_calls: Optional[List[Dict[str, Any]]] = None
63
+ tool_call_id: Optional[str] = None
64
+
65
+ def to_dict(self) -> Dict[str, Any]:
66
+ """Convert to dictionary format for LLM API."""
67
+ result = {"role": self.role, "content": self.content}
68
+ if self.name:
69
+ result["name"] = self.name
70
+ if self.tool_calls:
71
+ result["tool_calls"] = self.tool_calls
72
+ if self.tool_call_id:
73
+ result["tool_call_id"] = self.tool_call_id
74
+ return result
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: Dict[str, Any]) -> "LLMMessage":
78
+ """Create from dictionary format."""
79
+ return cls(
80
+ role=data["role"],
81
+ content=data.get("content", ""),
82
+ name=data.get("name"),
83
+ tool_calls=data.get("tool_calls"),
84
+ tool_call_id=data.get("tool_call_id"),
85
+ )