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,147 @@
1
+ """
2
+ Domain whitelist management for email access control.
3
+
4
+ Loads domain whitelist definitions from atlas.domain-whitelist.json and provides
5
+ validation for user email domains.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional, Set
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class DomainWhitelistConfig:
19
+ """Configuration for domain whitelist."""
20
+ domains: Set[str]
21
+ subdomain_matching: bool
22
+ version: str
23
+ description: str
24
+
25
+
26
+ class DomainWhitelistManager:
27
+ """Manages domain whitelist configuration and validation."""
28
+
29
+ def __init__(self, config_path: Optional[Path] = None):
30
+ """Initialize the domain whitelist manager.
31
+
32
+ Args:
33
+ config_path: Path to domain-whitelist.json. If None, uses default location.
34
+ """
35
+ self.config: Optional[DomainWhitelistConfig] = None
36
+ self.config_loaded: bool = False
37
+
38
+ if config_path is None:
39
+ # Try to find config in standard locations
40
+ backend_root = Path(__file__).parent.parent
41
+ project_root = backend_root.parent
42
+
43
+ search_paths = [
44
+ project_root / "config" / "overrides" / "domain-whitelist.json",
45
+ project_root / "config" / "defaults" / "domain-whitelist.json",
46
+ backend_root / "configfilesadmin" / "domain-whitelist.json",
47
+ backend_root / "configfiles" / "domain-whitelist.json",
48
+ ]
49
+
50
+ for path in search_paths:
51
+ if path.exists():
52
+ config_path = path
53
+ break
54
+
55
+ if config_path and config_path.exists():
56
+ self._load_config(config_path)
57
+ else:
58
+ logger.warning("No domain-whitelist.json found, whitelist validation disabled")
59
+ self.config = DomainWhitelistConfig(
60
+ domains=set(),
61
+ subdomain_matching=True,
62
+ version="1.0",
63
+ description="No config loaded"
64
+ )
65
+ self.config_loaded = False
66
+
67
+ def _load_config(self, config_path: Path):
68
+ """Load domain whitelist configuration from JSON file."""
69
+ try:
70
+ with open(config_path, 'r', encoding='utf-8') as f:
71
+ config_data = json.load(f)
72
+
73
+ # Extract domains from the list of domain objects
74
+ domains = set()
75
+ for domain_entry in config_data.get('domains', []):
76
+ if isinstance(domain_entry, dict):
77
+ domains.add(domain_entry.get('domain', '').lower())
78
+ elif isinstance(domain_entry, str):
79
+ domains.add(domain_entry.lower())
80
+
81
+ self.config = DomainWhitelistConfig(
82
+ domains=domains,
83
+ subdomain_matching=config_data.get('subdomain_matching', True),
84
+ version=config_data.get('version', '1.0'),
85
+ description=config_data.get('description', '')
86
+ )
87
+ self.config_loaded = True
88
+
89
+ logger.info(f"Loaded {len(self.config.domains)} domains from {config_path}")
90
+
91
+ except Exception as e:
92
+ logger.error(f"Error loading domain-whitelist.json: {e}")
93
+ logger.warning("Whitelist validation disabled due to config error")
94
+ # Use empty config on error
95
+ self.config = DomainWhitelistConfig(
96
+ domains=set(),
97
+ subdomain_matching=True,
98
+ version="1.0",
99
+ description="Error loading config"
100
+ )
101
+ self.config_loaded = False
102
+
103
+ def is_domain_allowed(self, email: str) -> bool:
104
+ """Check if an email address is from an allowed domain.
105
+
106
+ Note: This method only validates against the whitelist.
107
+ The FEATURE_DOMAIN_WHITELIST_ENABLED flag controls whether
108
+ the middleware uses this validation.
109
+
110
+ Args:
111
+ email: Email address to validate
112
+
113
+ Returns:
114
+ True if domain is allowed, False otherwise
115
+ """
116
+ # If config wasn't successfully loaded, allow all (fail open)
117
+ if not self.config_loaded:
118
+ return True
119
+
120
+ if not email or "@" not in email:
121
+ return False
122
+
123
+ domain = email.split("@", 1)[1].lower()
124
+
125
+ # Check if domain is in whitelist (O(1) lookup)
126
+ if domain in self.config.domains:
127
+ return True
128
+
129
+ # Check subdomains if enabled - check each parent level
130
+ if self.config.subdomain_matching:
131
+ # Split domain and check each parent level
132
+ # e.g., for "mail.dept.sandia.gov" check: "dept.sandia.gov", "sandia.gov"
133
+ parts = domain.split(".")
134
+ for i in range(1, len(parts)):
135
+ parent_domain = ".".join(parts[i:])
136
+ if parent_domain in self.config.domains:
137
+ return True
138
+
139
+ return False
140
+
141
+ def get_domains(self) -> Set[str]:
142
+ """Get the set of whitelisted domains.
143
+
144
+ Returns:
145
+ Set of allowed domains
146
+ """
147
+ return self.config.domains if self.config else set()
@@ -0,0 +1,82 @@
1
+ """Email domain whitelist validation middleware.
2
+
3
+ This middleware enforces that users must have email addresses from whitelisted
4
+ domains. Enabled/disabled via the FEATURE_DOMAIN_WHITELIST_ENABLED feature flag.
5
+ Domain list is loaded from atlas.domain-whitelist.json.
6
+ """
7
+
8
+ import logging
9
+
10
+ from fastapi import Request
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.responses import JSONResponse, RedirectResponse, Response
13
+
14
+ from atlas.core.domain_whitelist import DomainWhitelistManager
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class DomainWhitelistMiddleware(BaseHTTPMiddleware):
20
+ """Middleware to enforce email domain whitelist restrictions."""
21
+
22
+ def __init__(self, app, auth_redirect_url: str = "/auth"):
23
+ """Initialize domain whitelist middleware.
24
+
25
+ Args:
26
+ app: ASGI application
27
+ auth_redirect_url: URL to redirect to on auth failure (default: /auth)
28
+ """
29
+ super().__init__(app)
30
+ self.auth_redirect_url = auth_redirect_url
31
+ self.whitelist_manager = DomainWhitelistManager()
32
+
33
+ logger.info(f"Domain whitelist middleware loaded: {len(self.whitelist_manager.get_domains())} domains (config_loaded={self.whitelist_manager.config_loaded})")
34
+
35
+ async def dispatch(self, request: Request, call_next) -> Response:
36
+ """Check if user email is from a whitelisted domain.
37
+
38
+ Args:
39
+ request: Incoming HTTP request
40
+ call_next: Next middleware/handler in chain
41
+
42
+ Returns:
43
+ Response from next handler if authorized, or 403/redirect if not
44
+ """
45
+ # Skip check for health endpoint and auth redirect endpoint
46
+ if request.url.path == '/api/health' or request.url.path == self.auth_redirect_url:
47
+ return await call_next(request)
48
+
49
+ # Get email from request state (set by AuthMiddleware)
50
+ email = getattr(request.state, "user_email", None)
51
+
52
+ if not email or "@" not in email:
53
+ logger.warning("Domain whitelist check failed: missing or invalid email")
54
+ return self._unauthorized_response(request, "User email required")
55
+
56
+ # Check if domain is allowed
57
+ if not self.whitelist_manager.is_domain_allowed(email):
58
+ domain = email.split("@", 1)[1].lower()
59
+ logger.warning(f"Domain whitelist check failed: unauthorized domain {domain}")
60
+ return self._unauthorized_response(
61
+ request,
62
+ "Access restricted to whitelisted domains"
63
+ )
64
+
65
+ return await call_next(request)
66
+
67
+ def _unauthorized_response(self, request: Request, detail: str) -> Response:
68
+ """Return appropriate unauthorized response based on endpoint type.
69
+
70
+ Args:
71
+ request: Incoming HTTP request
72
+ detail: Error detail message
73
+
74
+ Returns:
75
+ JSONResponse for API endpoints, RedirectResponse for others
76
+ """
77
+ if request.url.path.startswith('/api/'):
78
+ return JSONResponse(
79
+ status_code=403,
80
+ content={"detail": detail}
81
+ )
82
+ return RedirectResponse(url=self.auth_redirect_url, status_code=302)
@@ -0,0 +1,28 @@
1
+ """
2
+ Minimal HTTP client stub for basic chat functionality.
3
+ This is a temporary implementation for testing.
4
+ """
5
+
6
+ import logging
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def create_rag_client(base_url: str = "", timeout: float = 30.0) -> Any:
13
+ """
14
+ Create a simple RAG client stub.
15
+ For basic chat, this just returns a mock client.
16
+ """
17
+ class MockRAGClient:
18
+ def __init__(self):
19
+ pass
20
+
21
+ async def query(self, *args, **kwargs):
22
+ """Mock RAG query - returns empty result."""
23
+ return {
24
+ "content": "RAG not available in basic chat mode",
25
+ "metadata": {}
26
+ }
27
+
28
+ return MockRAGClient()
@@ -0,0 +1,102 @@
1
+ """
2
+ Minimal utilities for basic chat functionality.
3
+ """
4
+
5
+ import logging
6
+ import re
7
+ from typing import Any
8
+
9
+ from fastapi import Request
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _CONTROL_CHARS_RE = re.compile(r'[\x00-\x1f\x7f-\x9f]')
14
+ # Matches Unicode line separators (LINE SEPARATOR and PARAGRAPH SEPARATOR)
15
+ _UNICODE_NEWLINES_RE = re.compile(r'[\u2028\u2029]')
16
+ # Matches explicit CR, LF, and CRLF for maximal coverage
17
+ _STANDARD_NEWLINES_RE = re.compile(r'(\r\n|\r|\n)')
18
+
19
+ def sanitize_for_logging(value: Any) -> str:
20
+ """
21
+ Sanitize a value for safe logging by removing ALL newlines (including Unicode and CRLF)
22
+ and control characters, to defend against log injection.
23
+
24
+ Removes ASCII control characters (C0 and C1 ranges), CR/LF in any combination,
25
+ and Unicode line/paragraph separators. This includes characters
26
+ like newlines (\\n, \\r, \\r\\n, U+2028, U+2029), tabs, escape sequences, and other
27
+ non-printable characters that could be used to manipulate log output or inject fake log entries.
28
+
29
+ Args:
30
+ value: Any value to sanitize. If not a string, it will be converted
31
+ to string representation first.
32
+
33
+ Returns:
34
+ str: Sanitized string with all control and newline characters removed.
35
+
36
+ Examples:
37
+ >>> sanitize_for_logging("Hello\\nWorld")
38
+ 'HelloWorld'
39
+ >>> sanitize_for_logging("Test\\x1b[31mRed\\x1b[0m")
40
+ 'TestRed'
41
+ >>> sanitize_for_logging("Fake\u2028Log")
42
+ 'FakeLog'
43
+ >>> sanitize_for_logging("line1\\r\\nline2\\rline3\\nline4")
44
+ 'line1line2line3line4'
45
+ >>> sanitize_for_logging("A\u2028B\u2029C")
46
+ 'ABC'
47
+ >>> sanitize_for_logging(123)
48
+ '123'
49
+ """
50
+ if value is None:
51
+ return ''
52
+ if not isinstance(value, str):
53
+ value = str(value)
54
+ value = _CONTROL_CHARS_RE.sub('', value)
55
+ value = _UNICODE_NEWLINES_RE.sub('', value)
56
+ value = _STANDARD_NEWLINES_RE.sub('', value)
57
+ return value
58
+
59
+
60
+ def summarize_tool_approval_response_for_logging(data: Any) -> str:
61
+ """Return a non-sensitive summary of a tool approval response payload.
62
+
63
+ This is intentionally conservative: it never logs tool argument values or
64
+ rejection reasons because these can contain sensitive user content.
65
+
66
+ Expected input shape (from websocket):
67
+ {
68
+ "type": "tool_approval_response",
69
+ "tool_call_id": "...",
70
+ "approved": true/false,
71
+ "arguments": {...},
72
+ "reason": "..."
73
+ }
74
+ """
75
+ if not isinstance(data, dict):
76
+ return f"type=tool_approval_response payload_type={sanitize_for_logging(type(data).__name__)}"
77
+
78
+ tool_call_id = sanitize_for_logging(data.get("tool_call_id"))
79
+ approved_raw = data.get("approved", False)
80
+ approved = bool(approved_raw)
81
+
82
+ arguments = data.get("arguments")
83
+ has_arguments = arguments is not None
84
+ arguments_count = len(arguments) if isinstance(arguments, dict) else (1 if has_arguments else 0)
85
+
86
+ reason = data.get("reason")
87
+ has_reason = bool(reason)
88
+
89
+ return (
90
+ "type=tool_approval_response "
91
+ f"tool_call_id={tool_call_id} "
92
+ f"approved={approved} "
93
+ f"has_arguments={has_arguments} "
94
+ f"arguments_count={arguments_count} "
95
+ f"has_reason={has_reason}"
96
+ )
97
+
98
+
99
+
100
+ async def get_current_user(request: Request) -> str:
101
+ """Get current user from request state (set by middleware)."""
102
+ return getattr(request.state, 'user_email', 'test@test.com')
@@ -0,0 +1,59 @@
1
+ """
2
+ Metrics logging utility for tracking user activities without capturing sensitive data.
3
+
4
+ This module provides a centralized way to log user activity metrics that:
5
+ - Use the [METRIC] prefix for easy filtering
6
+ - Include the username for tracking
7
+ - Only log metadata (counts, sizes, types)
8
+ - NEVER log sensitive data like prompts, tool arguments, filenames, or error details
9
+
10
+ Usage:
11
+ from atlas.core.metrics_logger import log_metric
12
+
13
+ log_metric("llm_call", user_email, model="gpt-4", message_count=5)
14
+ log_metric("tool_call", user_email, tool_name="calculator")
15
+ log_metric("file_upload", user_email, file_size=1024, content_type="application/pdf")
16
+ log_metric("error", user_email, error_type="rate_limit")
17
+ """
18
+
19
+ import logging
20
+ from typing import Any, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def log_metric(
26
+ event_type: str,
27
+ user_email: Optional[str] = None,
28
+ **kwargs: Any
29
+ ) -> None:
30
+ """
31
+ Log a metric event for user activity tracking.
32
+
33
+ This function respects the FEATURE_METRICS_LOGGING_ENABLED setting.
34
+ When disabled, no metrics are logged.
35
+
36
+ Args:
37
+ event_type: Type of event (e.g., "llm_call", "tool_call", "file_upload", "error")
38
+ user_email: User's email address (will be sanitized)
39
+ **kwargs: Additional metadata to log (only non-sensitive data)
40
+ """
41
+ # Import here to avoid circular dependencies
42
+ from atlas.core.log_sanitizer import sanitize_for_logging
43
+ from atlas.modules.config import config_manager
44
+
45
+ if not config_manager.app_settings.feature_metrics_logging_enabled:
46
+ return
47
+
48
+ sanitized_user = sanitize_for_logging(user_email) if user_email else "unknown"
49
+
50
+ parts = [f"[METRIC] [{sanitized_user}] {event_type}"]
51
+
52
+ if kwargs:
53
+ metadata_parts = [
54
+ f"{key}={sanitize_for_logging(value)}"
55
+ for key, value in kwargs.items()
56
+ ]
57
+ parts.append(" ".join(metadata_parts))
58
+
59
+ logger.info(" ".join(parts))
@@ -0,0 +1,131 @@
1
+ """FastAPI middleware for authentication and logging."""
2
+
3
+ import logging
4
+
5
+ from fastapi import Request
6
+ from fastapi.responses import JSONResponse, RedirectResponse
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.responses import Response
9
+
10
+ from atlas.core.auth import get_user_from_aws_alb_jwt, get_user_from_header
11
+ from atlas.core.capabilities import verify_file_token
12
+ from atlas.infrastructure.app_factory import app_factory
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AuthMiddleware(BaseHTTPMiddleware):
18
+ """Middleware to handle authentication and logging."""
19
+
20
+ def __init__(
21
+ self,
22
+ app,
23
+ debug_mode: bool = False,
24
+ auth_header_name: str = "X-User-Email",
25
+ auth_header_type: str = "email-string",
26
+ auth_aws_expected_alb_arn: str = "",
27
+ auth_aws_region: str = "us-east-1",
28
+ proxy_secret_enabled: bool = False,
29
+ proxy_secret_header: str = "X-Proxy-Secret",
30
+ proxy_secret: str = None,
31
+ auth_redirect_url: str = "/auth"
32
+ ):
33
+ super().__init__(app)
34
+ self.debug_mode = debug_mode
35
+ self.auth_header_name = auth_header_name
36
+ self.auth_header_type = auth_header_type
37
+ self.auth_aws_expected_alb_arn = auth_aws_expected_alb_arn
38
+ self.auth_aws_region = auth_aws_region
39
+ self.proxy_secret_enabled = proxy_secret_enabled
40
+ self.proxy_secret_header = proxy_secret_header
41
+ self.proxy_secret = proxy_secret
42
+ self.auth_redirect_url = auth_redirect_url
43
+
44
+ async def dispatch(self, request: Request, call_next) -> Response:
45
+ # Log request
46
+ logger.debug("Request: %s %s", request.method, request.url.path)
47
+
48
+ # Skip auth for static files, health check, and configured auth endpoint
49
+ if (request.url.path.startswith('/static') or
50
+ request.url.path == '/api/health' or
51
+ request.url.path == self.auth_redirect_url):
52
+ return await call_next(request)
53
+
54
+ # Validate proxy secret if enabled (skip in debug mode for local development)
55
+ if self.proxy_secret_enabled and self.proxy_secret and not self.debug_mode:
56
+ proxy_secret_value = request.headers.get(self.proxy_secret_header)
57
+
58
+ if not proxy_secret_value or proxy_secret_value != self.proxy_secret:
59
+ logger.warning(f"Invalid or missing proxy secret for {request.url.path}")
60
+ # Distinguish between API endpoints (return 401) and browser endpoints (redirect)
61
+ if request.url.path.startswith('/api/'):
62
+ return JSONResponse(
63
+ status_code=401,
64
+ content={"detail": "Unauthorized: Invalid proxy secret"}
65
+ )
66
+ else:
67
+ return RedirectResponse(url=self.auth_redirect_url, status_code=302)
68
+
69
+ # Check for capability token in download URLs (allows MCP servers to access files)
70
+ if request.url.path.startswith('/api/files/download/'):
71
+ token = request.query_params.get('token')
72
+ if token:
73
+ claims = verify_file_token(token)
74
+ if claims:
75
+ # Valid capability token - extract user from token and allow request
76
+ # Note: We only validate token authenticity here (authentication).
77
+ # The route handler validates that token's file key matches the requested
78
+ # file (authorization). This separation of concerns keeps middleware focused
79
+ # on authentication while route handlers handle resource-specific authorization.
80
+ user_email = claims.get('u')
81
+ if user_email:
82
+ logger.debug("Authenticated via capability token for user: %s", user_email)
83
+ request.state.user_email = user_email
84
+ return await call_next(request)
85
+ else:
86
+ logger.warning("Valid token but missing user email claim")
87
+ else:
88
+ logger.warning("Invalid capability token provided")
89
+
90
+ # Check authentication via configured header (default: X-User-Email)
91
+ user_email = None
92
+ if self.debug_mode:
93
+ # In debug mode, honor auth header if provided, otherwise use config test user
94
+ x_auth_header = request.headers.get(self.auth_header_name)
95
+ if x_auth_header:
96
+ # Apply same authentication logic as production for testing
97
+ if self.auth_header_type == "aws-alb-jwt":
98
+ user_email = get_user_from_aws_alb_jwt(x_auth_header, self.auth_aws_expected_alb_arn, self.auth_aws_region)
99
+ else:
100
+ user_email = get_user_from_header(x_auth_header)
101
+ else:
102
+ # Get test user from config
103
+ config_manager = app_factory.get_config_manager()
104
+ user_email = config_manager.app_settings.test_user
105
+ # logger.info(f"Debug mode: using user {user_email}")
106
+ else:
107
+ x_auth_header = request.headers.get(self.auth_header_name)
108
+
109
+ # Extract the user's email, depending on the datatype of auth header
110
+ if self.auth_header_type == "aws-alb-jwt": # Amazon Application Load Balancer
111
+ user_email = get_user_from_aws_alb_jwt(x_auth_header, self.auth_aws_expected_alb_arn, self.auth_aws_region)
112
+ else:
113
+ user_email = get_user_from_header(x_auth_header)
114
+
115
+ if not user_email:
116
+ # Distinguish between API endpoints (return 401) and browser endpoints (redirect)
117
+ if request.url.path.startswith('/api/'):
118
+ logger.warning(f"Missing authentication for API endpoint: {request.url.path}")
119
+ return JSONResponse(
120
+ status_code=401,
121
+ content={"detail": "Unauthorized"}
122
+ )
123
+ else:
124
+ logger.warning(f"Missing {self.auth_header_name}, redirecting to {self.auth_redirect_url}")
125
+ return RedirectResponse(url=self.auth_redirect_url, status_code=302)
126
+
127
+ # Add user to request state
128
+ request.state.user_email = user_email
129
+
130
+ response = await call_next(request)
131
+ return response