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,556 @@
1
+ """Secure per-user token storage for MCP server authentication.
2
+
3
+ This module provides encrypted storage for authentication tokens associated
4
+ with MCP servers on a per-user basis. Supports multiple token types:
5
+ - API keys
6
+ - JWT tokens
7
+ - Bearer tokens
8
+ - OAuth access tokens
9
+
10
+ Each user's tokens are isolated and encrypted using Fernet (AES-128-CBC).
11
+
12
+ Key format: "{user_email}:{server_name}"
13
+
14
+ Security considerations:
15
+ - Tokens are encrypted at rest using a key derived from environment variable
16
+ - Each user's tokens are stored separately (isolation by key)
17
+ - Token expiration is tracked and validated
18
+ - No plaintext tokens are logged
19
+
20
+ Updated: 2025-01-21
21
+ """
22
+
23
+ import base64
24
+ import json
25
+ import logging
26
+ import threading
27
+ import time
28
+ from dataclasses import asdict, dataclass
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Tuple
31
+
32
+ from cryptography.fernet import Fernet, InvalidToken
33
+ from cryptography.hazmat.primitives import hashes
34
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class AuthenticationRequiredException(Exception):
40
+ """Exception raised when a user needs to authenticate with an MCP server.
41
+
42
+ This exception carries information needed to initiate the OAuth flow
43
+ so the frontend can automatically redirect the user to authenticate.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ server_name: str,
49
+ auth_type: str,
50
+ message: str = "Authentication required",
51
+ oauth_start_url: Optional[str] = None,
52
+ ):
53
+ super().__init__(message)
54
+ self.server_name = server_name
55
+ self.auth_type = auth_type # "oauth", "jwt", "bearer", or "api_key"
56
+ self.oauth_start_url = oauth_start_url # URL to start OAuth flow (if oauth)
57
+ self.message = message
58
+
59
+ def to_dict(self) -> Dict[str, Any]:
60
+ """Convert exception info to a dict for frontend consumption."""
61
+ return {
62
+ "server_name": self.server_name,
63
+ "auth_type": self.auth_type,
64
+ "message": self.message,
65
+ "oauth_start_url": self.oauth_start_url,
66
+ }
67
+
68
+
69
+ def _make_token_key(user_email: str, server_name: str) -> str:
70
+ """Create a storage key from user email and server name.
71
+
72
+ Args:
73
+ user_email: User's email address
74
+ server_name: Name of the MCP server
75
+
76
+ Returns:
77
+ Combined key in format "user_email:server_name"
78
+ """
79
+ # Normalize to lowercase for consistent lookups
80
+ return f"{user_email.lower()}:{server_name}"
81
+
82
+
83
+ def _parse_token_key(key: str) -> Tuple[str, str]:
84
+ """Parse a storage key into user email and server name.
85
+
86
+ Args:
87
+ key: Combined key in format "user_email:server_name"
88
+
89
+ Returns:
90
+ Tuple of (user_email, server_name)
91
+
92
+ Raises:
93
+ ValueError: If key format is invalid
94
+ """
95
+ parts = key.split(":", 1)
96
+ if len(parts) != 2:
97
+ raise ValueError(f"Invalid token key format: {key}")
98
+ return parts[0], parts[1]
99
+
100
+
101
+ @dataclass
102
+ class StoredToken:
103
+ """Represents a stored authentication token."""
104
+
105
+ token_type: str # "api_key", "bearer", "jwt", "oauth_access", "oauth_refresh"
106
+ token_value: str # The actual token (will be encrypted at rest)
107
+ user_email: str # User who owns this token
108
+ server_name: str # MCP server this token is associated with
109
+ created_at: float # Unix timestamp when token was stored
110
+ expires_at: Optional[float] = None # Unix timestamp when token expires (if known)
111
+ scopes: Optional[str] = None # OAuth scopes (space-separated)
112
+ refresh_token: Optional[str] = None # OAuth refresh token (if available)
113
+ metadata: Optional[Dict[str, Any]] = None # Additional metadata
114
+
115
+ def is_expired(self, buffer_seconds: int = 60) -> bool:
116
+ """Check if token is expired or will expire within buffer period."""
117
+ if self.expires_at is None:
118
+ return False # No expiration set, assume valid
119
+ return time.time() >= (self.expires_at - buffer_seconds)
120
+
121
+ def time_until_expiry(self) -> Optional[float]:
122
+ """Get seconds until token expires, or None if no expiration."""
123
+ if self.expires_at is None:
124
+ return None
125
+ return max(0, self.expires_at - time.time())
126
+
127
+ def to_dict(self) -> Dict[str, Any]:
128
+ """Convert to dictionary for serialization."""
129
+ return asdict(self)
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: Dict[str, Any]) -> "StoredToken":
133
+ """Create from dictionary."""
134
+ return cls(**data)
135
+
136
+
137
+ class MCPTokenStorage:
138
+ """Secure encrypted storage for per-user MCP authentication tokens.
139
+
140
+ Tokens are stored in an encrypted JSON file on disk, keyed by the
141
+ combination of user email and server name. The encryption key is
142
+ derived from the MCP_TOKEN_ENCRYPTION_KEY environment variable using
143
+ PBKDF2. If no key is set, a random key is generated (tokens will not
144
+ persist across restarts in this case).
145
+
146
+ Storage location: {storage_dir}/mcp_tokens.enc
147
+ """
148
+
149
+ # Salt for key derivation (constant, not secret)
150
+ _SALT = b"atlas-mcp-token-storage-v1"
151
+
152
+ def __init__(
153
+ self,
154
+ storage_dir: Optional[Path] = None,
155
+ encryption_key: Optional[str] = None,
156
+ ):
157
+ """Initialize token storage.
158
+
159
+ Args:
160
+ storage_dir: Directory to store encrypted tokens. Defaults to
161
+ MCP_TOKEN_STORAGE_DIR or config/secure
162
+ encryption_key: Base64-encoded encryption key or passphrase.
163
+ Defaults to MCP_TOKEN_ENCRYPTION_KEY setting.
164
+ """
165
+ # Import here to avoid circular imports
166
+ from atlas.modules.config.config_manager import get_app_settings
167
+ app_settings = get_app_settings()
168
+
169
+ self._storage_dir = storage_dir or self._get_storage_dir(app_settings)
170
+ self._storage_dir.mkdir(parents=True, exist_ok=True)
171
+ self._storage_file = self._storage_dir / "mcp_tokens.enc"
172
+
173
+ # Get or generate encryption key (prefer passed arg, then settings)
174
+ key_source = encryption_key or app_settings.mcp_token_encryption_key
175
+ if key_source:
176
+ self._fernet = self._derive_fernet(key_source)
177
+ logger.info("Token storage initialized with configured encryption key")
178
+ else:
179
+ # Generate ephemeral key (tokens won't persist across restarts)
180
+ ephemeral_key = Fernet.generate_key()
181
+ self._fernet = Fernet(ephemeral_key)
182
+ logger.warning(
183
+ "No MCP_TOKEN_ENCRYPTION_KEY set. Using ephemeral key - "
184
+ "tokens will not persist across application restarts."
185
+ )
186
+
187
+ # Thread lock for concurrent access
188
+ self._lock = threading.Lock()
189
+
190
+ # In-memory cache of decrypted tokens
191
+ # Key format: "user_email:server_name"
192
+ self._tokens: Dict[str, StoredToken] = {}
193
+ self._load_tokens()
194
+
195
+ def _get_storage_dir(self, app_settings) -> Path:
196
+ """Get storage directory from settings or default locations.
197
+
198
+ Args:
199
+ app_settings: AppSettings instance with mcp_token_storage_dir
200
+
201
+ Returns:
202
+ Path to storage directory
203
+ """
204
+ # Check configured directory first
205
+ if app_settings.mcp_token_storage_dir:
206
+ configured_path = Path(app_settings.mcp_token_storage_dir)
207
+ try:
208
+ configured_path.mkdir(parents=True, exist_ok=True)
209
+ logger.info(f"Using token storage directory: {configured_path}")
210
+ return configured_path
211
+ except (PermissionError, OSError) as e:
212
+ logger.warning(f"Cannot use MCP_TOKEN_STORAGE_DIR={app_settings.mcp_token_storage_dir}: {e}")
213
+
214
+ # Try project root locations
215
+ candidates = [
216
+ Path(__file__).parent.parent.parent.parent / "config" / "secure",
217
+ Path(__file__).parent.parent.parent.parent / "runtime" / "tokens",
218
+ Path.home() / ".atlas-ui" / "tokens",
219
+ ]
220
+ for candidate in candidates:
221
+ try:
222
+ candidate.mkdir(parents=True, exist_ok=True)
223
+ # Test write access
224
+ test_file = candidate / ".write_test"
225
+ test_file.write_text("test")
226
+ test_file.unlink()
227
+ return candidate
228
+ except (PermissionError, OSError):
229
+ continue
230
+
231
+ # Fallback to temp directory
232
+ import tempfile
233
+ return Path(tempfile.gettempdir()) / "atlas-mcp-tokens"
234
+
235
+ def _derive_fernet(self, key_source: str) -> Fernet:
236
+ """Derive Fernet key from passphrase or base64 key."""
237
+ try:
238
+ # Try to use as direct Fernet key (base64-encoded 32 bytes)
239
+ return Fernet(key_source.encode())
240
+ except (ValueError, Exception):
241
+ # Derive key from passphrase using PBKDF2
242
+ kdf = PBKDF2HMAC(
243
+ algorithm=hashes.SHA256(),
244
+ length=32,
245
+ salt=self._SALT,
246
+ iterations=480000, # OWASP recommended minimum
247
+ )
248
+ derived_key = base64.urlsafe_b64encode(
249
+ kdf.derive(key_source.encode())
250
+ )
251
+ return Fernet(derived_key)
252
+
253
+ def _load_tokens(self) -> None:
254
+ """Load and decrypt tokens from storage file."""
255
+ if not self._storage_file.exists():
256
+ self._tokens = {}
257
+ return
258
+
259
+ try:
260
+ encrypted_data = self._storage_file.read_bytes()
261
+ decrypted_data = self._fernet.decrypt(encrypted_data)
262
+ tokens_dict = json.loads(decrypted_data.decode())
263
+
264
+ self._tokens = {
265
+ key: StoredToken.from_dict(token_data)
266
+ for key, token_data in tokens_dict.items()
267
+ }
268
+ logger.info(f"Loaded {len(self._tokens)} encrypted tokens from storage")
269
+
270
+ except InvalidToken:
271
+ logger.error(
272
+ "Failed to decrypt token storage - encryption key may have changed. "
273
+ "Tokens will be reset."
274
+ )
275
+ self._tokens = {}
276
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
277
+ logger.error(f"Corrupted token storage: {e}. Tokens will be reset.")
278
+ self._tokens = {}
279
+
280
+ def _save_tokens(self) -> None:
281
+ """Encrypt and save tokens to storage file."""
282
+ try:
283
+ tokens_dict = {
284
+ key: token.to_dict()
285
+ for key, token in self._tokens.items()
286
+ }
287
+ json_data = json.dumps(tokens_dict, indent=2)
288
+ encrypted_data = self._fernet.encrypt(json_data.encode())
289
+
290
+ # Atomic write: write to temp file then rename
291
+ temp_file = self._storage_file.with_suffix(".tmp")
292
+ temp_file.write_bytes(encrypted_data)
293
+ temp_file.rename(self._storage_file)
294
+
295
+ logger.debug(f"Saved {len(self._tokens)} encrypted tokens to storage")
296
+
297
+ except Exception as e:
298
+ logger.error(f"Failed to save tokens: {e}")
299
+ raise
300
+
301
+ def store_token(
302
+ self,
303
+ user_email: str,
304
+ server_name: str,
305
+ token_value: str,
306
+ token_type: str = "bearer",
307
+ expires_at: Optional[float] = None,
308
+ scopes: Optional[str] = None,
309
+ refresh_token: Optional[str] = None,
310
+ metadata: Optional[Dict[str, Any]] = None,
311
+ ) -> StoredToken:
312
+ """Store an authentication token for a user and MCP server.
313
+
314
+ Args:
315
+ user_email: User's email address
316
+ server_name: Name of the MCP server
317
+ token_value: The token value (JWT, access token, etc.)
318
+ token_type: Type of token ("bearer", "oauth_access", "jwt")
319
+ expires_at: Unix timestamp when token expires
320
+ scopes: OAuth scopes (space-separated string)
321
+ refresh_token: OAuth refresh token if available
322
+ metadata: Additional metadata to store
323
+
324
+ Returns:
325
+ The stored token object
326
+ """
327
+ token = StoredToken(
328
+ token_type=token_type,
329
+ token_value=token_value,
330
+ user_email=user_email.lower(),
331
+ server_name=server_name,
332
+ created_at=time.time(),
333
+ expires_at=expires_at,
334
+ scopes=scopes,
335
+ refresh_token=refresh_token,
336
+ metadata=metadata,
337
+ )
338
+
339
+ key = _make_token_key(user_email, server_name)
340
+ with self._lock:
341
+ self._tokens[key] = token
342
+ self._save_tokens()
343
+
344
+ from atlas.core.log_sanitizer import sanitize_for_logging
345
+ logger.info(
346
+ f"Stored {token_type} token for user and server '{sanitize_for_logging(server_name)}' "
347
+ f"(expires: {'never' if expires_at is None else time.ctime(expires_at)})"
348
+ )
349
+ return token
350
+
351
+ def get_token(self, user_email: str, server_name: str) -> Optional[StoredToken]:
352
+ """Get stored token for a user and MCP server.
353
+
354
+ Args:
355
+ user_email: User's email address
356
+ server_name: Name of the MCP server
357
+
358
+ Returns:
359
+ StoredToken if found, None otherwise
360
+ """
361
+ key = _make_token_key(user_email, server_name)
362
+ token = self._tokens.get(key)
363
+ if token is None:
364
+ return None
365
+
366
+ # Log warning if expired (but still return it - caller may want to refresh)
367
+ if token.is_expired():
368
+ logger.debug(f"Token for server '{server_name}' has expired")
369
+
370
+ return token
371
+
372
+ def get_valid_token(self, user_email: str, server_name: str) -> Optional[StoredToken]:
373
+ """Get stored token only if not expired.
374
+
375
+ Args:
376
+ user_email: User's email address
377
+ server_name: Name of the MCP server
378
+
379
+ Returns:
380
+ StoredToken if found and not expired, None otherwise
381
+ """
382
+ token = self.get_token(user_email, server_name)
383
+ if token is None or token.is_expired():
384
+ return None
385
+ return token
386
+
387
+ def remove_token(self, user_email: str, server_name: str) -> bool:
388
+ """Remove stored token for a user and MCP server.
389
+
390
+ Args:
391
+ user_email: User's email address
392
+ server_name: Name of the MCP server
393
+
394
+ Returns:
395
+ True if token was removed, False if not found
396
+ """
397
+ key = _make_token_key(user_email, server_name)
398
+ with self._lock:
399
+ if key in self._tokens:
400
+ del self._tokens[key]
401
+ self._save_tokens()
402
+ from atlas.core.log_sanitizer import sanitize_for_logging
403
+ logger.info(f"Removed token for server '{sanitize_for_logging(server_name)}'")
404
+ return True
405
+ return False
406
+
407
+ def get_user_tokens(self, user_email: str) -> Dict[str, StoredToken]:
408
+ """Get all tokens for a specific user.
409
+
410
+ Args:
411
+ user_email: User's email address
412
+
413
+ Returns:
414
+ Dictionary mapping server names to tokens
415
+ """
416
+ user_email_lower = user_email.lower()
417
+ return {
418
+ _parse_token_key(key)[1]: token # Extract server_name from key
419
+ for key, token in self._tokens.items()
420
+ if token.user_email == user_email_lower
421
+ }
422
+
423
+ def get_user_auth_status(self, user_email: str) -> Dict[str, Dict[str, Any]]:
424
+ """Get authentication status for all servers for a user.
425
+
426
+ Returns metadata about tokens without revealing token values.
427
+
428
+ Args:
429
+ user_email: User's email address
430
+
431
+ Returns:
432
+ Dictionary mapping server names to auth status info
433
+ """
434
+ user_tokens = self.get_user_tokens(user_email)
435
+ return {
436
+ server_name: {
437
+ "authenticated": True,
438
+ "token_type": token.token_type,
439
+ "created_at": token.created_at,
440
+ "expires_at": token.expires_at,
441
+ "is_expired": token.is_expired(),
442
+ "time_until_expiry": token.time_until_expiry(),
443
+ "has_refresh_token": token.refresh_token is not None,
444
+ "scopes": token.scopes,
445
+ }
446
+ for server_name, token in user_tokens.items()
447
+ }
448
+
449
+ def list_all_tokens_metadata(self) -> List[Dict[str, Any]]:
450
+ """List metadata for all stored tokens (admin use).
451
+
452
+ Returns token metadata without revealing token values.
453
+
454
+ Returns:
455
+ List of token metadata dictionaries
456
+ """
457
+ return [
458
+ {
459
+ "user_email": token.user_email,
460
+ "server_name": token.server_name,
461
+ "token_type": token.token_type,
462
+ "created_at": token.created_at,
463
+ "expires_at": token.expires_at,
464
+ "is_expired": token.is_expired(),
465
+ "has_refresh_token": token.refresh_token is not None,
466
+ "scopes": token.scopes,
467
+ }
468
+ for token in self._tokens.values()
469
+ ]
470
+
471
+ def update_oauth_tokens(
472
+ self,
473
+ user_email: str,
474
+ server_name: str,
475
+ access_token: str,
476
+ expires_at: Optional[float] = None,
477
+ refresh_token: Optional[str] = None,
478
+ scopes: Optional[str] = None,
479
+ ) -> StoredToken:
480
+ """Update OAuth tokens after a refresh or new authorization.
481
+
482
+ Preserves existing metadata and refresh token if new one not provided.
483
+
484
+ Args:
485
+ user_email: User's email address
486
+ server_name: Name of the MCP server
487
+ access_token: New access token
488
+ expires_at: Unix timestamp when token expires
489
+ refresh_token: New refresh token (or None to keep existing)
490
+ scopes: OAuth scopes
491
+
492
+ Returns:
493
+ Updated StoredToken
494
+ """
495
+ existing = self.get_token(user_email, server_name)
496
+
497
+ return self.store_token(
498
+ user_email=user_email,
499
+ server_name=server_name,
500
+ token_value=access_token,
501
+ token_type="oauth_access",
502
+ expires_at=expires_at,
503
+ scopes=scopes or (existing.scopes if existing else None),
504
+ refresh_token=refresh_token or (existing.refresh_token if existing else None),
505
+ metadata=existing.metadata if existing else None,
506
+ )
507
+
508
+ def clear_user_tokens(self, user_email: str) -> int:
509
+ """Remove all tokens for a specific user.
510
+
511
+ Args:
512
+ user_email: User's email address
513
+
514
+ Returns:
515
+ Number of tokens removed
516
+ """
517
+ user_email_lower = user_email.lower()
518
+ with self._lock:
519
+ keys_to_remove = [
520
+ key for key, token in self._tokens.items()
521
+ if token.user_email == user_email_lower
522
+ ]
523
+
524
+ for key in keys_to_remove:
525
+ del self._tokens[key]
526
+
527
+ if keys_to_remove:
528
+ self._save_tokens()
529
+ logger.info(f"Cleared {len(keys_to_remove)} tokens for user")
530
+
531
+ return len(keys_to_remove)
532
+
533
+ def clear_all(self) -> int:
534
+ """Remove all stored tokens (admin use).
535
+
536
+ Returns:
537
+ Number of tokens removed
538
+ """
539
+ with self._lock:
540
+ count = len(self._tokens)
541
+ self._tokens.clear()
542
+ self._save_tokens()
543
+ logger.info(f"Cleared all {count} stored tokens")
544
+ return count
545
+
546
+
547
+ # Global token storage instance (lazy initialization)
548
+ _token_storage: Optional[MCPTokenStorage] = None
549
+
550
+
551
+ def get_token_storage() -> MCPTokenStorage:
552
+ """Get the global token storage instance."""
553
+ global _token_storage
554
+ if _token_storage is None:
555
+ _token_storage = MCPTokenStorage()
556
+ return _token_storage
@@ -0,0 +1,130 @@
1
+ """Prompt provider module for loading and caching prompt templates.
2
+
3
+ Centralizes prompt path resolution & template retrieval so core services stay
4
+ focused on orchestration/business logic.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ from typing import Dict, Optional
11
+
12
+ from atlas.modules.config import ConfigManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PromptProvider:
18
+ """Loads and caches prompt templates based on application configuration."""
19
+
20
+ def __init__(self, config_manager: ConfigManager):
21
+ self.config_manager = config_manager
22
+ self._cache: Dict[str, str] = {}
23
+ # Resolve base path (relative paths resolved against repo root)
24
+ app_settings = self.config_manager.app_settings
25
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
26
+ base_candidate = app_settings.prompt_base_path
27
+ if not os.path.isabs(base_candidate):
28
+ self.base_path = os.path.join(repo_root, base_candidate)
29
+ else:
30
+ self.base_path = base_candidate
31
+
32
+ def _load_template(self, filename: str) -> Optional[str]:
33
+ cache_key = filename
34
+ if cache_key in self._cache:
35
+ return self._cache[cache_key]
36
+ path = os.path.join(self.base_path, filename)
37
+ if not os.path.exists(path):
38
+ logger.warning("Prompt template not found: %s", path)
39
+ return None
40
+ try:
41
+ with open(path, "r", encoding="utf-8") as f:
42
+ content = f.read()
43
+ self._cache[cache_key] = content
44
+ return content
45
+ except Exception as e: # pragma: no cover
46
+ logger.error("Failed reading prompt template %s: %s", path, e)
47
+ return None
48
+
49
+ def get_tool_synthesis_prompt(self, user_question: str) -> Optional[str]:
50
+ """Return formatted tool synthesis prompt or None if unavailable."""
51
+ filename = self.config_manager.app_settings.tool_synthesis_prompt_filename
52
+ template = self._load_template(filename)
53
+ if not template:
54
+ return None
55
+ try:
56
+ return template.format(user_question=user_question.strip())
57
+ except Exception as e: # pragma: no cover - safeguard
58
+ logger.warning("Formatting tool synthesis prompt failed: %s", e)
59
+ return None
60
+
61
+ def get_agent_reason_prompt(
62
+ self,
63
+ user_question: str,
64
+ files_manifest: Optional[str] = None,
65
+ last_observation: Optional[str] = None,
66
+ ) -> Optional[str]:
67
+ """Return formatted agent reason prompt text or None if unavailable.
68
+
69
+ Expects template placeholders: {user_question}, {files_manifest}, {last_observation}
70
+ Missing values are rendered as empty strings.
71
+ """
72
+ filename = self.config_manager.app_settings.agent_reason_prompt_filename
73
+ template = self._load_template(filename)
74
+ if not template:
75
+ return None
76
+ try:
77
+ return template.format(
78
+ user_question=(user_question or "").strip(),
79
+ files_manifest=(files_manifest or ""),
80
+ last_observation=(last_observation or ""),
81
+ )
82
+ except Exception as e: # pragma: no cover
83
+ logger.warning("Formatting agent reason prompt failed: %s", e)
84
+ return None
85
+
86
+ def get_agent_observe_prompt(
87
+ self,
88
+ user_question: str,
89
+ tool_summaries: str,
90
+ step: int,
91
+ ) -> Optional[str]:
92
+ """Return formatted agent observe prompt text or None if unavailable.
93
+
94
+ Expects template placeholders: {user_question}, {tool_summaries}, {step}
95
+ """
96
+ filename = self.config_manager.app_settings.agent_observe_prompt_filename
97
+ template = self._load_template(filename)
98
+ if not template:
99
+ return None
100
+ try:
101
+ return template.format(
102
+ user_question=(user_question or "").strip(),
103
+ tool_summaries=(tool_summaries or ""),
104
+ step=step,
105
+ )
106
+ except Exception as e: # pragma: no cover
107
+ logger.warning("Formatting agent observe prompt failed: %s", e)
108
+ return None
109
+
110
+ def get_system_prompt(self, user_email: Optional[str] = None) -> Optional[str]:
111
+ """Return formatted system prompt text or None if unavailable.
112
+
113
+ Expects template placeholder: {user_email}
114
+ Missing values are rendered as empty strings.
115
+ """
116
+ filename = self.config_manager.app_settings.system_prompt_filename
117
+ template = self._load_template(filename)
118
+ if not template:
119
+ return None
120
+ try:
121
+ return template.format(
122
+ user_email=(user_email or ""),
123
+ )
124
+ except Exception as e: # pragma: no cover
125
+ logger.warning("Formatting system prompt failed: %s", e)
126
+ return None
127
+
128
+ def clear_cache(self) -> None:
129
+ """Clear in-memory prompt cache (e.g., after config reload)."""
130
+ self._cache.clear()