codeframe-ai 0.9.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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,735 @@
1
+ """Secure credential management for CodeFRAME.
2
+
3
+ This module provides:
4
+ - Platform-native keyring integration (primary storage)
5
+ - Encrypted file fallback when keyring unavailable
6
+ - Environment variable override support
7
+ - Credential validation and rotation
8
+
9
+ Security features:
10
+ - Fernet encryption for file-based storage
11
+ - File permissions enforced at 600 (owner-only)
12
+ - Machine-specific encryption keys
13
+ - Audit logging for credential operations
14
+
15
+ Usage:
16
+ from codeframe.core.credentials import CredentialManager, CredentialProvider
17
+
18
+ manager = CredentialManager()
19
+ api_key = manager.get_credential(CredentialProvider.LLM_ANTHROPIC)
20
+ manager.set_credential(CredentialProvider.GIT_GITHUB, "ghp_token")
21
+ """
22
+
23
+ import base64
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import platform
29
+ import uuid
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from enum import Enum
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ try:
37
+ import keyring
38
+ from keyring.errors import KeyringError
39
+ KEYRING_AVAILABLE = True
40
+ except ImportError:
41
+ KEYRING_AVAILABLE = False
42
+ keyring = None
43
+ KeyringError = Exception
44
+
45
+ from cryptography.fernet import Fernet, InvalidToken
46
+ from cryptography.hazmat.primitives import hashes
47
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
48
+
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # Constants
53
+ KEYRING_SERVICE_NAME = "codeframe-credentials"
54
+ ENCRYPTED_FILE_NAME = "credentials.encrypted"
55
+ SALT_FILE_NAME = "salt"
56
+ DEFAULT_STORAGE_DIR = Path.home() / ".codeframe"
57
+
58
+
59
+ class CredentialSource(str, Enum):
60
+ """Source of a credential."""
61
+
62
+ ENVIRONMENT = "environment"
63
+ STORED = "stored"
64
+ NOT_FOUND = "not_found"
65
+
66
+
67
+ class CredentialProvider(Enum):
68
+ """Supported credential provider types.
69
+
70
+ Each provider type has associated metadata for env var mapping
71
+ and display purposes.
72
+ """
73
+
74
+ LLM_ANTHROPIC = ("ANTHROPIC_API_KEY", "Anthropic (Claude)")
75
+ LLM_OPENAI = ("OPENAI_API_KEY", "OpenAI (GPT)")
76
+ GIT_GITHUB = ("GITHUB_TOKEN", "GitHub")
77
+ GIT_GITLAB = ("GITLAB_TOKEN", "GitLab")
78
+ CICD_GENERIC = ("CICD_TOKEN", "CI/CD")
79
+ DATABASE = ("DATABASE_URL", "Database")
80
+
81
+ def __init__(self, env_var: str, display_name: str):
82
+ self._env_var = env_var
83
+ self._display_name = display_name
84
+
85
+ @property
86
+ def env_var(self) -> str:
87
+ """Environment variable name for this provider."""
88
+ return self._env_var
89
+
90
+ @property
91
+ def display_name(self) -> str:
92
+ """Human-readable display name."""
93
+ return self._display_name
94
+
95
+
96
+ @dataclass
97
+ class Credential:
98
+ """A stored credential with metadata.
99
+
100
+ Attributes:
101
+ provider: The provider type (LLM, Git, etc.)
102
+ value: The actual credential value (API key, token, etc.)
103
+ name: Optional friendly name for the credential
104
+ metadata: Additional metadata (scopes, permissions, etc.)
105
+ created_at: When the credential was stored
106
+ expires_at: Optional expiration timestamp
107
+ """
108
+
109
+ provider: CredentialProvider
110
+ value: str
111
+ name: Optional[str] = None
112
+ metadata: dict[str, Any] = field(default_factory=dict)
113
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
114
+ expires_at: Optional[datetime] = None
115
+
116
+ @property
117
+ def is_expired(self) -> bool:
118
+ """Check if credential has expired."""
119
+ exp = self.expires_at
120
+ if exp is None:
121
+ return False
122
+ if exp.tzinfo is None:
123
+ exp = exp.replace(tzinfo=timezone.utc)
124
+ return datetime.now(timezone.utc) > exp
125
+
126
+ @property
127
+ def masked_value(self) -> str:
128
+ """Get masked version of value for display."""
129
+ if len(self.value) <= 8:
130
+ return "***"
131
+ return f"{self.value[:4]}...{self.value[-4:]}"
132
+
133
+ def to_safe_dict(self) -> dict[str, Any]:
134
+ """Convert to dictionary, excluding actual value."""
135
+ return {
136
+ "provider": self.provider.name,
137
+ "name": self.name,
138
+ "masked_value": self.masked_value,
139
+ "metadata": self.metadata,
140
+ "created_at": self.created_at.isoformat() if self.created_at else None,
141
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
142
+ "is_expired": self.is_expired,
143
+ }
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ """Convert to dictionary for storage (includes value)."""
147
+ return {
148
+ "provider": self.provider.name,
149
+ "value": self.value,
150
+ "name": self.name,
151
+ "metadata": self.metadata,
152
+ "created_at": self.created_at.isoformat() if self.created_at else None,
153
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
154
+ }
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict[str, Any]) -> "Credential":
158
+ """Create Credential from dictionary."""
159
+ provider = CredentialProvider[data["provider"]]
160
+
161
+ created_at = None
162
+ if data.get("created_at"):
163
+ created_at = datetime.fromisoformat(data["created_at"])
164
+
165
+ expires_at = None
166
+ if data.get("expires_at"):
167
+ expires_at = datetime.fromisoformat(data["expires_at"])
168
+
169
+ return cls(
170
+ provider=provider,
171
+ value=data["value"],
172
+ name=data.get("name"),
173
+ metadata=data.get("metadata", {}),
174
+ created_at=created_at or datetime.now(timezone.utc),
175
+ expires_at=expires_at,
176
+ )
177
+
178
+
179
+ @dataclass
180
+ class CredentialInfo:
181
+ """Summary information about a credential (no actual value)."""
182
+
183
+ provider: CredentialProvider
184
+ source: CredentialSource
185
+ name: Optional[str] = None
186
+ masked_value: Optional[str] = None
187
+ is_expired: bool = False
188
+ last_validated: Optional[datetime] = None
189
+ metadata: dict[str, Any] = field(default_factory=dict)
190
+
191
+
192
+ def derive_encryption_key(salt_file: Path) -> bytes:
193
+ """Derive encryption key from machine-specific data.
194
+
195
+ Uses PBKDF2 with machine ID and a persistent salt to create
196
+ a Fernet-compatible encryption key.
197
+
198
+ Args:
199
+ salt_file: Path to store/retrieve the salt
200
+
201
+ Returns:
202
+ Fernet-compatible key (base64 encoded)
203
+ """
204
+ # Get or create salt
205
+ if salt_file.exists():
206
+ with open(salt_file, "rb") as f:
207
+ salt = f.read()
208
+ # Validate salt file integrity
209
+ if len(salt) != 16:
210
+ raise ValueError(
211
+ f"Invalid salt file at {salt_file}: expected 16 bytes, got {len(salt)}. "
212
+ "Delete the salt file to regenerate (note: this will make existing "
213
+ "credentials inaccessible)."
214
+ )
215
+ else:
216
+ salt = os.urandom(16)
217
+ salt_file.parent.mkdir(parents=True, exist_ok=True)
218
+ with open(salt_file, "wb") as f:
219
+ f.write(salt)
220
+ # Secure permissions
221
+ salt_file.chmod(0o600)
222
+
223
+ # Get machine-specific identifier
224
+ machine_id = _get_machine_id()
225
+
226
+ # Derive key using PBKDF2
227
+ kdf = PBKDF2HMAC(
228
+ algorithm=hashes.SHA256(),
229
+ length=32,
230
+ salt=salt,
231
+ iterations=480000,
232
+ )
233
+ key = base64.urlsafe_b64encode(kdf.derive(machine_id.encode()))
234
+
235
+ return key
236
+
237
+
238
+ def _get_machine_id() -> str:
239
+ """Get a machine-specific identifier.
240
+
241
+ Uses platform-specific stable identifiers when available:
242
+ - Linux: /etc/machine-id
243
+ - Windows: MachineGuid from registry
244
+ - Fallback: hostname + machine type + MAC address
245
+
246
+ Note: MAC address (uuid.getnode) can be randomized on WiFi adapters
247
+ on privacy-focused systems, so we prefer OS-level machine IDs.
248
+ """
249
+ components = []
250
+
251
+ # Try Linux machine-id first (most stable)
252
+ machine_id_path = Path("/etc/machine-id")
253
+ if machine_id_path.exists():
254
+ try:
255
+ machine_id = machine_id_path.read_text().strip()
256
+ if machine_id:
257
+ components.append(machine_id)
258
+ except (PermissionError, OSError):
259
+ pass
260
+
261
+ # Try Windows MachineGuid
262
+ if platform.system() == "Windows" and not components:
263
+ try:
264
+ import winreg
265
+ with winreg.OpenKey(
266
+ winreg.HKEY_LOCAL_MACHINE,
267
+ r"SOFTWARE\Microsoft\Cryptography"
268
+ ) as key:
269
+ machine_guid, _ = winreg.QueryValueEx(key, "MachineGuid")
270
+ if machine_guid:
271
+ components.append(machine_guid)
272
+ except (ImportError, OSError, FileNotFoundError):
273
+ pass
274
+
275
+ # Fallback: use portable identifiers
276
+ if not components:
277
+ components = [
278
+ platform.node(),
279
+ platform.machine(),
280
+ str(uuid.getnode()), # MAC address (less stable on some systems)
281
+ ]
282
+
283
+ combined = "-".join(components)
284
+ return hashlib.sha256(combined.encode()).hexdigest()
285
+
286
+
287
+ def validate_credential_format(
288
+ provider: CredentialProvider,
289
+ value: str,
290
+ ) -> bool:
291
+ """Validate credential format for a provider.
292
+
293
+ Args:
294
+ provider: The credential provider type
295
+ value: The credential value to validate
296
+
297
+ Returns:
298
+ True if format appears valid, False otherwise
299
+ """
300
+ if not value or len(value) < 5:
301
+ return False
302
+
303
+ if provider == CredentialProvider.LLM_ANTHROPIC:
304
+ # Anthropic keys start with "sk-ant-" (e.g., sk-ant-api03-...)
305
+ return len(value) >= 20 and value.startswith("sk-ant-")
306
+
307
+ elif provider == CredentialProvider.LLM_OPENAI:
308
+ # OpenAI keys start with "sk-" (legacy) or "sk-proj-" (project-scoped)
309
+ return len(value) >= 20 and value.startswith("sk-")
310
+
311
+ elif provider == CredentialProvider.GIT_GITHUB:
312
+ # GitHub PATs: ghp_ (classic) or github_pat_ (fine-grained)
313
+ return len(value) >= 10 and (
314
+ value.startswith("ghp_") or
315
+ value.startswith("github_pat_") or
316
+ value.startswith("gho_") or # OAuth
317
+ value.startswith("ghs_") # Server-to-server
318
+ )
319
+
320
+ elif provider == CredentialProvider.GIT_GITLAB:
321
+ # GitLab tokens start with "glpat-"
322
+ return len(value) >= 20 and value.startswith("glpat-")
323
+
324
+ # Default: just check minimum length
325
+ return len(value) >= 5
326
+
327
+
328
+ class CredentialStore:
329
+ """Low-level credential storage.
330
+
331
+ Uses platform keyring as primary storage with encrypted file fallback.
332
+ """
333
+
334
+ def __init__(self, storage_dir: Optional[Path] = None):
335
+ """Initialize credential store.
336
+
337
+ Args:
338
+ storage_dir: Directory for encrypted file storage
339
+ """
340
+ self.storage_dir = storage_dir or DEFAULT_STORAGE_DIR
341
+ self._keyring_available = self._check_keyring()
342
+ self._fernet: Optional[Fernet] = None
343
+
344
+ def _check_keyring(self) -> bool:
345
+ """Check if keyring is available and working."""
346
+ if not KEYRING_AVAILABLE:
347
+ return False
348
+
349
+ try:
350
+ # Try to get the keyring backend
351
+ kr = keyring.get_keyring()
352
+ # Check if it's a real backend (not fail keyring)
353
+ if "fail" in kr.__class__.__name__.lower():
354
+ return False
355
+ return True
356
+ except Exception:
357
+ return False
358
+
359
+ def _get_fernet(self) -> Fernet:
360
+ """Get or create Fernet instance for encryption."""
361
+ if self._fernet is None:
362
+ salt_file = self.storage_dir / SALT_FILE_NAME
363
+ key = derive_encryption_key(salt_file)
364
+ self._fernet = Fernet(key)
365
+ return self._fernet
366
+
367
+ def _get_encrypted_file_path(self) -> Path:
368
+ """Get path to encrypted credentials file."""
369
+ return self.storage_dir / ENCRYPTED_FILE_NAME
370
+
371
+ def _load_encrypted_store(self) -> dict[str, dict]:
372
+ """Load all credentials from encrypted file.
373
+
374
+ Returns:
375
+ Dictionary of stored credentials, or empty dict if file doesn't exist.
376
+
377
+ Note:
378
+ Returns empty dict on decryption failure (e.g., machine ID changed).
379
+ This is intentional - credentials become inaccessible on new machines.
380
+ """
381
+ file_path = self._get_encrypted_file_path()
382
+ if not file_path.exists():
383
+ return {}
384
+
385
+ try:
386
+ with open(file_path, "rb") as f:
387
+ encrypted_data = f.read()
388
+
389
+ fernet = self._get_fernet()
390
+ decrypted = fernet.decrypt(encrypted_data)
391
+ return json.loads(decrypted.decode())
392
+ except InvalidToken:
393
+ # Decryption failed - likely machine ID changed or file corrupted
394
+ logger.error(
395
+ "Failed to decrypt credentials file. This can happen if the machine ID "
396
+ "changed (e.g., new machine, VM clone). Stored credentials are inaccessible. "
397
+ "Re-run 'cf auth setup' to reconfigure credentials."
398
+ )
399
+ return {}
400
+ except json.JSONDecodeError as e:
401
+ # Decryption succeeded but JSON is invalid - file corruption
402
+ logger.error(f"Credentials file corrupted (invalid JSON): {e}")
403
+ return {}
404
+ except PermissionError as e:
405
+ logger.error(f"Permission denied reading credentials file: {e}")
406
+ return {}
407
+ except OSError as e:
408
+ logger.error(f"Failed to read credentials file: {e}")
409
+ return {}
410
+
411
+ def _save_encrypted_store(self, store: dict[str, dict]) -> None:
412
+ """Save all credentials to encrypted file."""
413
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
414
+
415
+ file_path = self._get_encrypted_file_path()
416
+ fernet = self._get_fernet()
417
+
418
+ data = json.dumps(store).encode()
419
+ encrypted = fernet.encrypt(data)
420
+
421
+ # Write atomically
422
+ temp_path = file_path.with_suffix(".tmp")
423
+ try:
424
+ with open(temp_path, "wb") as f:
425
+ f.write(encrypted)
426
+ temp_path.chmod(0o600)
427
+ temp_path.replace(file_path)
428
+ file_path.chmod(0o600)
429
+ finally:
430
+ try:
431
+ temp_path.unlink(missing_ok=True)
432
+ except OSError:
433
+ pass
434
+
435
+ def store(self, credential: Credential) -> None:
436
+ """Store a credential securely.
437
+
438
+ Tries keyring first, falls back to encrypted file.
439
+
440
+ Args:
441
+ credential: The credential to store
442
+ """
443
+ key = credential.provider.name
444
+ data = json.dumps(credential.to_dict())
445
+
446
+ # Try keyring first
447
+ if self._keyring_available:
448
+ try:
449
+ keyring.set_password(KEYRING_SERVICE_NAME, key, data)
450
+ logger.debug(f"Stored {key} in keyring")
451
+ return
452
+ except Exception as e:
453
+ logger.warning(f"Keyring storage failed, using encrypted file: {e}")
454
+ try:
455
+ keyring.delete_password(KEYRING_SERVICE_NAME, key)
456
+ except Exception:
457
+ pass
458
+ self._keyring_available = False
459
+
460
+ # Fall back to encrypted file
461
+ store = self._load_encrypted_store()
462
+ store[key] = credential.to_dict()
463
+ self._save_encrypted_store(store)
464
+ logger.debug(f"Stored {key} in encrypted file")
465
+
466
+ def retrieve(self, provider: CredentialProvider) -> Optional[Credential]:
467
+ """Retrieve a credential.
468
+
469
+ Tries keyring first, falls back to encrypted file.
470
+
471
+ Args:
472
+ provider: The provider type to retrieve
473
+
474
+ Returns:
475
+ Credential if found, None otherwise
476
+ """
477
+ key = provider.name
478
+
479
+ # Try keyring first
480
+ if self._keyring_available:
481
+ try:
482
+ data = keyring.get_password(KEYRING_SERVICE_NAME, key)
483
+ if data:
484
+ return Credential.from_dict(json.loads(data))
485
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as e:
486
+ logger.warning(f"Malformed credential data in keyring for {key}: {e}")
487
+ except Exception as e:
488
+ logger.debug(f"Keyring retrieval failed: {e}")
489
+
490
+ # Fall back to encrypted file
491
+ store = self._load_encrypted_store()
492
+ if key in store:
493
+ try:
494
+ return Credential.from_dict(store[key])
495
+ except (KeyError, TypeError, ValueError) as e:
496
+ logger.warning(f"Malformed credential data in encrypted store for {key}: {e}")
497
+ return None
498
+
499
+ return None
500
+
501
+ def delete(self, provider: CredentialProvider) -> None:
502
+ """Delete a credential.
503
+
504
+ Args:
505
+ provider: The provider type to delete
506
+ """
507
+ key = provider.name
508
+
509
+ # Try keyring
510
+ if self._keyring_available:
511
+ try:
512
+ keyring.delete_password(KEYRING_SERVICE_NAME, key)
513
+ logger.debug(f"Deleted {key} from keyring")
514
+ except Exception as e:
515
+ logger.warning(f"Keyring deletion failed: {e}")
516
+ raise
517
+
518
+ # Also remove from encrypted file (if exists)
519
+ store = self._load_encrypted_store()
520
+ if key in store:
521
+ del store[key]
522
+ self._save_encrypted_store(store)
523
+ logger.debug(f"Deleted {key} from encrypted file")
524
+
525
+ def list_providers(self) -> list[CredentialProvider]:
526
+ """List all stored provider types from encrypted file storage.
527
+
528
+ Note:
529
+ This only returns credentials stored in the encrypted file.
530
+ Credentials stored directly in the system keyring (when keyring
531
+ is available and working) are not enumerable due to keyring API
532
+ limitations. However, CredentialManager.list_credentials()
533
+ checks all known provider types and will find keyring entries.
534
+
535
+ Returns:
536
+ List of providers that have stored credentials in encrypted file
537
+ """
538
+ providers = []
539
+
540
+ # Check encrypted file only - keyring doesn't support enumeration
541
+ store = self._load_encrypted_store()
542
+ for key in store:
543
+ try:
544
+ providers.append(CredentialProvider[key])
545
+ except KeyError:
546
+ logger.warning(f"Unknown provider in store: {key}")
547
+
548
+ return providers
549
+
550
+
551
+ class CredentialManager:
552
+ """High-level credential management API.
553
+
554
+ Provides environment variable override, storage abstraction,
555
+ and credential lifecycle management.
556
+ """
557
+
558
+ def __init__(self, storage_dir: Optional[Path] = None):
559
+ """Initialize credential manager.
560
+
561
+ Args:
562
+ storage_dir: Directory for credential storage
563
+ """
564
+ self._store = CredentialStore(storage_dir)
565
+
566
+ def get_credential(
567
+ self,
568
+ provider: CredentialProvider,
569
+ name: Optional[str] = None,
570
+ ) -> Optional[str]:
571
+ """Get credential value, checking env var first.
572
+
573
+ Args:
574
+ provider: The provider type
575
+ name: Optional credential name (unused for env var lookup)
576
+
577
+ Returns:
578
+ Credential value if found, None otherwise
579
+ """
580
+ # Check environment variable first
581
+ env_value = os.environ.get(provider.env_var)
582
+ if env_value:
583
+ logger.debug(f"Using {provider.env_var} from environment")
584
+ return env_value
585
+
586
+ # Fall back to store
587
+ credential = self._store.retrieve(provider)
588
+ if credential:
589
+ if credential.is_expired:
590
+ logger.warning(f"Credential for {provider.name} has expired")
591
+ return None
592
+ return credential.value
593
+
594
+ return None
595
+
596
+ def get_credential_source(
597
+ self,
598
+ provider: CredentialProvider,
599
+ ) -> CredentialSource:
600
+ """Determine where a credential comes from.
601
+
602
+ Args:
603
+ provider: The provider type
604
+
605
+ Returns:
606
+ CredentialSource indicating the source
607
+ """
608
+ if os.environ.get(provider.env_var):
609
+ return CredentialSource.ENVIRONMENT
610
+
611
+ credential = self._store.retrieve(provider)
612
+ if credential:
613
+ return CredentialSource.STORED
614
+
615
+ return CredentialSource.NOT_FOUND
616
+
617
+ def set_credential(
618
+ self,
619
+ provider: CredentialProvider,
620
+ value: str,
621
+ name: Optional[str] = None,
622
+ metadata: Optional[dict[str, Any]] = None,
623
+ expires_at: Optional[datetime] = None,
624
+ ) -> None:
625
+ """Store a credential securely.
626
+
627
+ Args:
628
+ provider: The provider type
629
+ value: The credential value
630
+ name: Optional friendly name
631
+ metadata: Optional metadata (scopes, etc.)
632
+ expires_at: Optional expiration timestamp
633
+ """
634
+ credential = Credential(
635
+ provider=provider,
636
+ value=value,
637
+ name=name,
638
+ metadata=metadata or {},
639
+ expires_at=expires_at,
640
+ )
641
+ self._store.store(credential)
642
+ logger.info(f"Stored credential for {provider.display_name}")
643
+
644
+ def delete_credential(self, provider: CredentialProvider) -> None:
645
+ """Delete a credential.
646
+
647
+ Args:
648
+ provider: The provider type to delete
649
+ """
650
+ self._store.delete(provider)
651
+ logger.info(f"Deleted credential for {provider.display_name}")
652
+
653
+ def rotate_credential(
654
+ self,
655
+ provider: CredentialProvider,
656
+ new_value: str,
657
+ metadata: Optional[dict[str, Any]] = None,
658
+ ) -> None:
659
+ """Rotate a credential atomically.
660
+
661
+ Stores new value, only removes old after successful store.
662
+
663
+ Args:
664
+ provider: The provider type
665
+ new_value: The new credential value
666
+ metadata: Optional updated metadata
667
+ """
668
+ # Get existing metadata if not provided
669
+ existing = self._store.retrieve(provider)
670
+ if existing and metadata is None:
671
+ metadata = existing.metadata
672
+
673
+ # Store new credential (overwrites old)
674
+ self.set_credential(
675
+ provider=provider,
676
+ value=new_value,
677
+ name=existing.name if existing else None,
678
+ metadata=metadata,
679
+ )
680
+ logger.info(f"Rotated credential for {provider.display_name}")
681
+
682
+ def list_credentials(self) -> list[CredentialInfo]:
683
+ """List all available credentials with their sources.
684
+
685
+ Returns:
686
+ List of CredentialInfo objects
687
+ """
688
+ credentials = []
689
+
690
+ # Check all providers
691
+ for provider in CredentialProvider:
692
+ source = self.get_credential_source(provider)
693
+
694
+ if source == CredentialSource.NOT_FOUND:
695
+ continue
696
+
697
+ info = CredentialInfo(
698
+ provider=provider,
699
+ source=source,
700
+ )
701
+
702
+ if source == CredentialSource.ENVIRONMENT:
703
+ env_value = os.environ.get(provider.env_var, "")
704
+ if len(env_value) > 8:
705
+ info.masked_value = f"{env_value[:4]}...{env_value[-4:]}"
706
+ else:
707
+ info.masked_value = "***"
708
+
709
+ elif source == CredentialSource.STORED:
710
+ cred = self._store.retrieve(provider)
711
+ if cred:
712
+ info.name = cred.name
713
+ info.masked_value = cred.masked_value
714
+ info.is_expired = cred.is_expired
715
+ info.metadata = cred.metadata
716
+
717
+ credentials.append(info)
718
+
719
+ return credentials
720
+
721
+ def validate_credential_format(
722
+ self,
723
+ provider: CredentialProvider,
724
+ value: str,
725
+ ) -> bool:
726
+ """Validate credential format.
727
+
728
+ Args:
729
+ provider: The provider type
730
+ value: The credential value
731
+
732
+ Returns:
733
+ True if format appears valid
734
+ """
735
+ return validate_credential_format(provider, value)