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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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)
|