codeshift 0.3.7__py3-none-any.whl → 0.5.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.
- codeshift/__init__.py +2 -2
- codeshift/cli/__init__.py +1 -1
- codeshift/cli/commands/__init__.py +1 -1
- codeshift/cli/commands/auth.py +46 -30
- codeshift/cli/commands/scan.py +2 -5
- codeshift/cli/commands/upgrade.py +69 -61
- codeshift/cli/commands/upgrade_all.py +1 -1
- codeshift/cli/main.py +2 -2
- codeshift/knowledge/generator.py +6 -0
- codeshift/knowledge_base/libraries/aiohttp.yaml +3 -3
- codeshift/knowledge_base/libraries/httpx.yaml +4 -4
- codeshift/knowledge_base/libraries/pytest.yaml +1 -1
- codeshift/knowledge_base/models.py +1 -0
- codeshift/migrator/llm_migrator.py +8 -12
- codeshift/migrator/transforms/marshmallow_transformer.py +50 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +191 -22
- codeshift/scanner/code_scanner.py +22 -2
- codeshift/utils/__init__.py +1 -1
- codeshift/utils/api_client.py +155 -15
- codeshift/utils/cache.py +1 -1
- codeshift/utils/credential_store.py +393 -0
- codeshift/utils/llm_client.py +111 -9
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/METADATA +4 -16
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/RECORD +28 -43
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/licenses/LICENSE +1 -1
- codeshift/api/__init__.py +0 -1
- codeshift/api/auth.py +0 -182
- codeshift/api/config.py +0 -73
- codeshift/api/database.py +0 -215
- codeshift/api/main.py +0 -103
- codeshift/api/models/__init__.py +0 -55
- codeshift/api/models/auth.py +0 -108
- codeshift/api/models/billing.py +0 -92
- codeshift/api/models/migrate.py +0 -42
- codeshift/api/models/usage.py +0 -116
- codeshift/api/routers/__init__.py +0 -5
- codeshift/api/routers/auth.py +0 -440
- codeshift/api/routers/billing.py +0 -395
- codeshift/api/routers/migrate.py +0 -304
- codeshift/api/routers/usage.py +0 -291
- codeshift/api/routers/webhooks.py +0 -289
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/WHEEL +0 -0
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/entry_points.txt +0 -0
- {codeshift-0.3.7.dist-info → codeshift-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Secure credential storage with encryption.
|
|
2
|
+
|
|
3
|
+
This module provides encrypted storage for sensitive credentials like API keys.
|
|
4
|
+
Credentials are encrypted using Fernet (AES-128-CBC) with a key derived from
|
|
5
|
+
a machine-specific identifier using PBKDF2-SHA256.
|
|
6
|
+
|
|
7
|
+
Security features:
|
|
8
|
+
- AES-128 encryption via Fernet
|
|
9
|
+
- Machine-bound encryption key (prevents credential theft across machines)
|
|
10
|
+
- PBKDF2-SHA256 key derivation with 100,000 iterations
|
|
11
|
+
- File permissions restricted to owner only (0o600)
|
|
12
|
+
- Automatic migration from plaintext credentials with secure deletion
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import secrets
|
|
22
|
+
import uuid
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Try to import cryptography, provide helpful error if not installed
|
|
29
|
+
try:
|
|
30
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
31
|
+
from cryptography.hazmat.backends import default_backend
|
|
32
|
+
from cryptography.hazmat.primitives import hashes
|
|
33
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
34
|
+
|
|
35
|
+
CRYPTOGRAPHY_AVAILABLE = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
CRYPTOGRAPHY_AVAILABLE = False
|
|
38
|
+
Fernet = None # type: ignore
|
|
39
|
+
InvalidToken = Exception # type: ignore
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CredentialDecryptionError(Exception):
|
|
43
|
+
"""Raised when credentials cannot be decrypted.
|
|
44
|
+
|
|
45
|
+
This typically occurs when:
|
|
46
|
+
- Credentials were created on a different machine
|
|
47
|
+
- The machine identifier has changed
|
|
48
|
+
- The credential file is corrupted
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str | None = None):
|
|
52
|
+
default_msg = (
|
|
53
|
+
"Failed to decrypt credentials. This may happen if credentials were "
|
|
54
|
+
"created on a different machine. Please run 'codeshift login' to "
|
|
55
|
+
"re-authenticate."
|
|
56
|
+
)
|
|
57
|
+
super().__init__(message or default_msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CredentialStore:
|
|
61
|
+
"""Secure encrypted credential storage.
|
|
62
|
+
|
|
63
|
+
Credentials are encrypted using a key derived from a machine-specific
|
|
64
|
+
identifier, making them non-portable between machines for security.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
store = CredentialStore()
|
|
68
|
+
store.save({"api_key": "secret123", "email": "user@example.com"})
|
|
69
|
+
creds = store.load()
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# Default paths
|
|
73
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "codeshift"
|
|
74
|
+
CREDENTIALS_FILE = "credentials.enc"
|
|
75
|
+
LEGACY_CREDENTIALS_FILE = "credentials.json"
|
|
76
|
+
SALT_FILE = ".salt"
|
|
77
|
+
|
|
78
|
+
# PBKDF2 parameters
|
|
79
|
+
PBKDF2_ITERATIONS = 100_000
|
|
80
|
+
KEY_LENGTH = 32 # 256 bits for Fernet
|
|
81
|
+
|
|
82
|
+
def __init__(self, config_dir: Path | None = None):
|
|
83
|
+
"""Initialize the credential store.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
config_dir: Directory for storing credentials. Defaults to ~/.config/codeshift
|
|
87
|
+
"""
|
|
88
|
+
self.config_dir = config_dir or self.DEFAULT_CONFIG_DIR
|
|
89
|
+
self.credentials_path = self.config_dir / self.CREDENTIALS_FILE
|
|
90
|
+
self.legacy_path = self.config_dir / self.LEGACY_CREDENTIALS_FILE
|
|
91
|
+
self.salt_path = self.config_dir / self.SALT_FILE
|
|
92
|
+
|
|
93
|
+
# Check if cryptography is available
|
|
94
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
95
|
+
logger.warning(
|
|
96
|
+
"cryptography package not installed. "
|
|
97
|
+
"Credentials will be stored in plaintext. "
|
|
98
|
+
"Install with: pip install cryptography"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _get_machine_identifier(self) -> str:
|
|
102
|
+
"""Get a stable machine identifier for key derivation.
|
|
103
|
+
|
|
104
|
+
Combines multiple system attributes to create a stable identifier
|
|
105
|
+
that persists across reboots but changes between machines.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A string identifier unique to this machine.
|
|
109
|
+
"""
|
|
110
|
+
components = []
|
|
111
|
+
|
|
112
|
+
# Platform info (stable)
|
|
113
|
+
components.append(platform.node())
|
|
114
|
+
components.append(platform.system())
|
|
115
|
+
components.append(platform.machine())
|
|
116
|
+
|
|
117
|
+
# Try to get hardware UUID (most stable)
|
|
118
|
+
try:
|
|
119
|
+
if platform.system() == "Darwin":
|
|
120
|
+
# macOS: Use IOPlatformUUID
|
|
121
|
+
import subprocess
|
|
122
|
+
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
timeout=5,
|
|
128
|
+
)
|
|
129
|
+
for line in result.stdout.split("\n"):
|
|
130
|
+
if "IOPlatformUUID" in line:
|
|
131
|
+
uuid_str = line.split('"')[-2]
|
|
132
|
+
components.append(uuid_str)
|
|
133
|
+
break
|
|
134
|
+
elif platform.system() == "Linux":
|
|
135
|
+
# Linux: Try machine-id
|
|
136
|
+
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"]:
|
|
137
|
+
try:
|
|
138
|
+
with open(path) as f:
|
|
139
|
+
components.append(f.read().strip())
|
|
140
|
+
break
|
|
141
|
+
except (OSError, PermissionError):
|
|
142
|
+
continue
|
|
143
|
+
elif platform.system() == "Windows":
|
|
144
|
+
# Windows: Use MachineGuid from registry
|
|
145
|
+
try:
|
|
146
|
+
import winreg
|
|
147
|
+
|
|
148
|
+
key = winreg.OpenKey( # type: ignore[attr-defined]
|
|
149
|
+
winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined]
|
|
150
|
+
r"SOFTWARE\Microsoft\Cryptography",
|
|
151
|
+
)
|
|
152
|
+
machine_guid = winreg.QueryValueEx(key, "MachineGuid")[0] # type: ignore[attr-defined]
|
|
153
|
+
components.append(machine_guid)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.debug(f"Could not get hardware UUID: {e}")
|
|
158
|
+
|
|
159
|
+
# Fallback to UUID based on hostname (less stable but better than nothing)
|
|
160
|
+
if len(components) < 4:
|
|
161
|
+
components.append(str(uuid.getnode()))
|
|
162
|
+
|
|
163
|
+
# Create hash of all components
|
|
164
|
+
combined = "|".join(components)
|
|
165
|
+
return hashlib.sha256(combined.encode()).hexdigest()
|
|
166
|
+
|
|
167
|
+
def _get_or_create_salt(self) -> bytes:
|
|
168
|
+
"""Get or create a random salt for key derivation.
|
|
169
|
+
|
|
170
|
+
The salt is stored alongside credentials and is required for decryption.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
32-byte random salt.
|
|
174
|
+
"""
|
|
175
|
+
if self.salt_path.exists():
|
|
176
|
+
try:
|
|
177
|
+
return self.salt_path.read_bytes()
|
|
178
|
+
except OSError as e:
|
|
179
|
+
logger.warning(f"Could not read salt file: {e}")
|
|
180
|
+
|
|
181
|
+
# Generate new salt
|
|
182
|
+
salt = secrets.token_bytes(32)
|
|
183
|
+
|
|
184
|
+
# Save salt with restricted permissions
|
|
185
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
self.salt_path.write_bytes(salt)
|
|
187
|
+
os.chmod(self.salt_path, 0o600)
|
|
188
|
+
|
|
189
|
+
return salt
|
|
190
|
+
|
|
191
|
+
def _derive_key(self, salt: bytes) -> bytes:
|
|
192
|
+
"""Derive an encryption key from the machine identifier.
|
|
193
|
+
|
|
194
|
+
Uses PBKDF2-SHA256 with 100,000 iterations for key derivation.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
salt: Random salt for key derivation.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
32-byte encryption key suitable for Fernet.
|
|
201
|
+
"""
|
|
202
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
203
|
+
raise ImportError(
|
|
204
|
+
"cryptography package required for encryption. "
|
|
205
|
+
"Install with: pip install cryptography"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
machine_id = self._get_machine_identifier()
|
|
209
|
+
|
|
210
|
+
kdf = PBKDF2HMAC(
|
|
211
|
+
algorithm=hashes.SHA256(),
|
|
212
|
+
length=self.KEY_LENGTH,
|
|
213
|
+
salt=salt,
|
|
214
|
+
iterations=self.PBKDF2_ITERATIONS,
|
|
215
|
+
backend=default_backend(),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
key = kdf.derive(machine_id.encode())
|
|
219
|
+
return base64.urlsafe_b64encode(key)
|
|
220
|
+
|
|
221
|
+
def _encrypt(self, data: dict) -> bytes:
|
|
222
|
+
"""Encrypt credential data.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
data: Dictionary of credentials to encrypt.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Encrypted bytes.
|
|
229
|
+
"""
|
|
230
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
231
|
+
# Fallback to plaintext (with warning logged in __init__)
|
|
232
|
+
return json.dumps(data).encode()
|
|
233
|
+
|
|
234
|
+
salt = self._get_or_create_salt()
|
|
235
|
+
key = self._derive_key(salt)
|
|
236
|
+
f = Fernet(key)
|
|
237
|
+
|
|
238
|
+
plaintext = json.dumps(data).encode()
|
|
239
|
+
return f.encrypt(plaintext)
|
|
240
|
+
|
|
241
|
+
def _decrypt(self, ciphertext: bytes) -> dict:
|
|
242
|
+
"""Decrypt credential data.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
ciphertext: Encrypted bytes.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Dictionary of credentials.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
CredentialDecryptionError: If decryption fails.
|
|
252
|
+
"""
|
|
253
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
254
|
+
# Assume plaintext if cryptography not available
|
|
255
|
+
try:
|
|
256
|
+
return cast(dict[Any, Any], json.loads(ciphertext.decode()))
|
|
257
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
258
|
+
raise CredentialDecryptionError(f"Invalid credential format: {e}") from e
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
salt = self._get_or_create_salt()
|
|
262
|
+
key = self._derive_key(salt)
|
|
263
|
+
f = Fernet(key)
|
|
264
|
+
|
|
265
|
+
plaintext = f.decrypt(ciphertext)
|
|
266
|
+
return cast(dict[Any, Any], json.loads(plaintext.decode()))
|
|
267
|
+
except InvalidToken as e:
|
|
268
|
+
raise CredentialDecryptionError(
|
|
269
|
+
"Failed to decrypt credentials. The encryption key may have changed "
|
|
270
|
+
"(different machine or hardware change). Please run 'codeshift login' "
|
|
271
|
+
"to re-authenticate."
|
|
272
|
+
) from e
|
|
273
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
274
|
+
raise CredentialDecryptionError(f"Corrupted credential data: {e}") from e
|
|
275
|
+
|
|
276
|
+
def save(self, credentials: dict[str, Any]) -> None:
|
|
277
|
+
"""Save credentials securely.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
credentials: Dictionary containing credentials (api_key, email, etc.)
|
|
281
|
+
"""
|
|
282
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
|
|
284
|
+
# Encrypt and save
|
|
285
|
+
encrypted = self._encrypt(credentials)
|
|
286
|
+
self.credentials_path.write_bytes(encrypted)
|
|
287
|
+
|
|
288
|
+
# Set restrictive permissions (owner read/write only)
|
|
289
|
+
os.chmod(self.credentials_path, 0o600)
|
|
290
|
+
|
|
291
|
+
logger.debug("Credentials saved securely")
|
|
292
|
+
|
|
293
|
+
def load(self) -> dict[str, Any] | None:
|
|
294
|
+
"""Load credentials from secure storage.
|
|
295
|
+
|
|
296
|
+
Automatically migrates from plaintext storage if found.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dictionary of credentials, or None if not found.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
CredentialDecryptionError: If credentials exist but cannot be decrypted.
|
|
303
|
+
"""
|
|
304
|
+
# Check for encrypted credentials first
|
|
305
|
+
if self.credentials_path.exists():
|
|
306
|
+
try:
|
|
307
|
+
ciphertext = self.credentials_path.read_bytes()
|
|
308
|
+
return self._decrypt(ciphertext)
|
|
309
|
+
except OSError as e:
|
|
310
|
+
logger.error(f"Could not read credentials file: {e}")
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
# Check for legacy plaintext credentials and migrate
|
|
314
|
+
if self.legacy_path.exists():
|
|
315
|
+
logger.info("Migrating credentials from plaintext to encrypted storage")
|
|
316
|
+
try:
|
|
317
|
+
plaintext = self.legacy_path.read_text()
|
|
318
|
+
credentials: dict[str, Any] = json.loads(plaintext)
|
|
319
|
+
|
|
320
|
+
# Save encrypted
|
|
321
|
+
self.save(credentials)
|
|
322
|
+
|
|
323
|
+
# Securely delete legacy file
|
|
324
|
+
self._secure_delete(self.legacy_path)
|
|
325
|
+
|
|
326
|
+
return credentials
|
|
327
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
328
|
+
logger.warning(f"Could not migrate legacy credentials: {e}")
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def delete(self) -> None:
|
|
334
|
+
"""Delete stored credentials securely."""
|
|
335
|
+
# Delete encrypted credentials
|
|
336
|
+
if self.credentials_path.exists():
|
|
337
|
+
self._secure_delete(self.credentials_path)
|
|
338
|
+
|
|
339
|
+
# Also delete legacy file if it exists
|
|
340
|
+
if self.legacy_path.exists():
|
|
341
|
+
self._secure_delete(self.legacy_path)
|
|
342
|
+
|
|
343
|
+
# Delete salt file
|
|
344
|
+
if self.salt_path.exists():
|
|
345
|
+
self._secure_delete(self.salt_path)
|
|
346
|
+
|
|
347
|
+
logger.debug("Credentials deleted")
|
|
348
|
+
|
|
349
|
+
def _secure_delete(self, path: Path) -> None:
|
|
350
|
+
"""Securely delete a file by overwriting before unlinking.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
path: Path to the file to delete.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
if path.exists():
|
|
357
|
+
# Overwrite with random data
|
|
358
|
+
size = path.stat().st_size
|
|
359
|
+
if size > 0:
|
|
360
|
+
with open(path, "wb") as f:
|
|
361
|
+
f.write(secrets.token_bytes(size))
|
|
362
|
+
f.flush()
|
|
363
|
+
os.fsync(f.fileno())
|
|
364
|
+
|
|
365
|
+
# Then delete
|
|
366
|
+
path.unlink()
|
|
367
|
+
except OSError as e:
|
|
368
|
+
logger.warning(f"Could not securely delete {path}: {e}")
|
|
369
|
+
# Try regular delete as fallback
|
|
370
|
+
try:
|
|
371
|
+
path.unlink()
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
def exists(self) -> bool:
|
|
376
|
+
"""Check if credentials exist.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
True if credentials file exists (encrypted or legacy).
|
|
380
|
+
"""
|
|
381
|
+
return self.credentials_path.exists() or self.legacy_path.exists()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Default credential store instance
|
|
385
|
+
_default_store: CredentialStore | None = None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def get_credential_store() -> CredentialStore:
|
|
389
|
+
"""Get the default credential store instance."""
|
|
390
|
+
global _default_store
|
|
391
|
+
if _default_store is None:
|
|
392
|
+
_default_store = CredentialStore()
|
|
393
|
+
return _default_store
|
codeshift/utils/llm_client.py
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
|
-
"""Anthropic Claude client wrapper for LLM-based migrations.
|
|
1
|
+
"""Anthropic Claude client wrapper for LLM-based migrations.
|
|
2
2
|
|
|
3
|
+
SECURITY NOTE: This module is intended for internal use only.
|
|
4
|
+
Direct access to the LLM client bypasses quota and billing controls.
|
|
5
|
+
Use the Codeshift API client (api_client.py) for all LLM operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
3
9
|
import os
|
|
10
|
+
import sys
|
|
4
11
|
from dataclasses import dataclass
|
|
5
12
|
|
|
6
13
|
from anthropic import Anthropic
|
|
7
14
|
|
|
15
|
+
# Prevent any exports from this module
|
|
16
|
+
__all__: list[str] = []
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DirectLLMAccessError(Exception):
|
|
22
|
+
"""Raised when code attempts to bypass the Codeshift API and access LLM directly.
|
|
23
|
+
|
|
24
|
+
The LLMClient is for internal server-side use only. Client applications
|
|
25
|
+
should use the Codeshift API which enforces quotas, billing, and access control.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str | None = None):
|
|
29
|
+
default_msg = (
|
|
30
|
+
"Direct LLM access is not permitted. "
|
|
31
|
+
"Use the Codeshift API client for LLM operations. "
|
|
32
|
+
"Run 'codeshift login' to authenticate."
|
|
33
|
+
)
|
|
34
|
+
super().__init__(message or default_msg)
|
|
35
|
+
|
|
8
36
|
|
|
9
37
|
@dataclass
|
|
10
38
|
class LLMResponse:
|
|
@@ -17,8 +45,54 @@ class LLMResponse:
|
|
|
17
45
|
error: str | None = None
|
|
18
46
|
|
|
19
47
|
|
|
20
|
-
|
|
21
|
-
"""
|
|
48
|
+
def _check_direct_access_attempt() -> None:
|
|
49
|
+
"""Check if this is an unauthorized direct access attempt.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
DirectLLMAccessError: If direct access is detected from external code.
|
|
53
|
+
"""
|
|
54
|
+
# Check if ANTHROPIC_API_KEY is set by the user (potential bypass attempt)
|
|
55
|
+
# This is allowed only for internal server use or development
|
|
56
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
57
|
+
# Check if this is being called from within the codeshift package
|
|
58
|
+
frame = sys._getframe(2) # Caller's caller
|
|
59
|
+
caller_file = frame.f_code.co_filename
|
|
60
|
+
|
|
61
|
+
# Allow internal codeshift modules and tests
|
|
62
|
+
allowed_paths = (
|
|
63
|
+
"codeshift/",
|
|
64
|
+
"codeshift\\", # Windows path
|
|
65
|
+
"tests/",
|
|
66
|
+
"tests\\",
|
|
67
|
+
"<stdin>", # Interactive Python
|
|
68
|
+
"<string>", # exec/eval
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
is_internal = any(path in caller_file for path in allowed_paths)
|
|
72
|
+
|
|
73
|
+
# Also check for environment flag indicating authorized server use
|
|
74
|
+
is_authorized_server = os.environ.get("CODESHIFT_SERVER_MODE") == "true"
|
|
75
|
+
|
|
76
|
+
if not is_internal and not is_authorized_server:
|
|
77
|
+
logger.warning(
|
|
78
|
+
"Direct LLM access attempt detected from: %s. "
|
|
79
|
+
"This bypasses quota and billing controls.",
|
|
80
|
+
caller_file,
|
|
81
|
+
)
|
|
82
|
+
raise DirectLLMAccessError(
|
|
83
|
+
"Direct use of ANTHROPIC_API_KEY detected. "
|
|
84
|
+
"This bypasses Codeshift's quota and billing system. "
|
|
85
|
+
"Use 'codeshift upgrade' commands which route through the API."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _LLMClient:
|
|
90
|
+
"""Internal client for interacting with Anthropic's Claude API.
|
|
91
|
+
|
|
92
|
+
SECURITY: This class is private (prefixed with _) and should not be
|
|
93
|
+
instantiated directly by external code. All LLM operations should go
|
|
94
|
+
through the Codeshift API which enforces access controls.
|
|
95
|
+
"""
|
|
22
96
|
|
|
23
97
|
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
24
98
|
MAX_TOKENS = 4096
|
|
@@ -27,13 +101,19 @@ class LLMClient:
|
|
|
27
101
|
self,
|
|
28
102
|
api_key: str | None = None,
|
|
29
103
|
model: str | None = None,
|
|
104
|
+
_bypass_check: bool = False,
|
|
30
105
|
):
|
|
31
106
|
"""Initialize the LLM client.
|
|
32
107
|
|
|
33
108
|
Args:
|
|
34
109
|
api_key: Anthropic API key. Defaults to ANTHROPIC_API_KEY env var.
|
|
35
110
|
model: Model to use. Defaults to claude-sonnet-4-20250514.
|
|
111
|
+
_bypass_check: Internal flag to bypass access check (for server use).
|
|
36
112
|
"""
|
|
113
|
+
# Security check for unauthorized direct access
|
|
114
|
+
if not _bypass_check:
|
|
115
|
+
_check_direct_access_attempt()
|
|
116
|
+
|
|
37
117
|
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
38
118
|
self.model = model or self.DEFAULT_MODEL
|
|
39
119
|
self._client: Anthropic | None = None
|
|
@@ -45,7 +125,7 @@ class LLMClient:
|
|
|
45
125
|
if not self.api_key:
|
|
46
126
|
raise ValueError(
|
|
47
127
|
"Anthropic API key not found. Set ANTHROPIC_API_KEY environment variable "
|
|
48
|
-
"or pass api_key to
|
|
128
|
+
"or pass api_key to _LLMClient."
|
|
49
129
|
)
|
|
50
130
|
self._client = Anthropic(api_key=self.api_key)
|
|
51
131
|
return self._client
|
|
@@ -209,13 +289,35 @@ Provide a brief explanation (2-3 sentences) of what changed and why:"""
|
|
|
209
289
|
return self.generate(prompt, system_prompt=system_prompt, max_tokens=500)
|
|
210
290
|
|
|
211
291
|
|
|
212
|
-
#
|
|
213
|
-
|
|
292
|
+
# Keep backward compatibility alias but mark as deprecated
|
|
293
|
+
# This will be removed in a future version
|
|
294
|
+
LLMClient = _LLMClient
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Singleton instance for internal use only
|
|
298
|
+
_default_client: _LLMClient | None = None
|
|
214
299
|
|
|
215
300
|
|
|
216
|
-
def
|
|
217
|
-
"""Get the default LLM client instance.
|
|
301
|
+
def _get_llm_client(_bypass_check: bool = False) -> _LLMClient:
|
|
302
|
+
"""Get the default LLM client instance.
|
|
303
|
+
|
|
304
|
+
INTERNAL USE ONLY: This function is for internal codeshift server use.
|
|
305
|
+
External code should use the Codeshift API client.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
_bypass_check: Internal flag to bypass access check (for server use).
|
|
309
|
+
"""
|
|
218
310
|
global _default_client
|
|
219
311
|
if _default_client is None:
|
|
220
|
-
_default_client =
|
|
312
|
+
_default_client = _LLMClient(_bypass_check=_bypass_check)
|
|
221
313
|
return _default_client
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Keep backward compatibility but with security check
|
|
317
|
+
def get_llm_client() -> _LLMClient:
|
|
318
|
+
"""Get the default LLM client instance.
|
|
319
|
+
|
|
320
|
+
DEPRECATED: Use the Codeshift API client (api_client.py) instead.
|
|
321
|
+
This function will be removed in a future version.
|
|
322
|
+
"""
|
|
323
|
+
return _get_llm_client(_bypass_check=False)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeshift
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: AI-powered CLI tool that migrates Python code to handle breaking dependency changes
|
|
5
|
-
Author:
|
|
5
|
+
Author: Ragab Technologies
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Ragab-Technologies/Codeshift
|
|
8
8
|
Project-URL: Repository, https://github.com/Ragab-Technologies/Codeshift
|
|
@@ -27,19 +27,9 @@ Requires-Dist: rich>=13.0
|
|
|
27
27
|
Requires-Dist: toml>=0.10
|
|
28
28
|
Requires-Dist: packaging>=23.0
|
|
29
29
|
Requires-Dist: httpx>=0.25
|
|
30
|
-
Requires-Dist: pytest>=8.4.2
|
|
31
|
-
Requires-Dist: nox>=2025.11.12
|
|
32
30
|
Requires-Dist: black>=24.10.0
|
|
33
|
-
Requires-Dist:
|
|
34
|
-
Requires-Dist:
|
|
35
|
-
Requires-Dist: pre-commit>=4.5.1
|
|
36
|
-
Provides-Extra: api
|
|
37
|
-
Requires-Dist: fastapi>=0.109.0; extra == "api"
|
|
38
|
-
Requires-Dist: uvicorn>=0.27.0; extra == "api"
|
|
39
|
-
Requires-Dist: supabase>=2.3.0; extra == "api"
|
|
40
|
-
Requires-Dist: stripe>=7.0.0; extra == "api"
|
|
41
|
-
Requires-Dist: pydantic-settings>=2.1.0; extra == "api"
|
|
42
|
-
Requires-Dist: email-validator>=2.0.0; extra == "api"
|
|
31
|
+
Requires-Dist: cryptography>=41.0
|
|
32
|
+
Requires-Dist: nox>=2025.11.12
|
|
43
33
|
Provides-Extra: dev
|
|
44
34
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
45
35
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -50,8 +40,6 @@ Requires-Dist: nox>=2024.0; extra == "dev"
|
|
|
50
40
|
Requires-Dist: pre-commit>=3.6.0; extra == "dev"
|
|
51
41
|
Requires-Dist: types-toml>=0.10; extra == "dev"
|
|
52
42
|
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
53
|
-
Provides-Extra: all
|
|
54
|
-
Requires-Dist: codeshift[api,dev]; extra == "all"
|
|
55
43
|
Dynamic: license-file
|
|
56
44
|
|
|
57
45
|
# Codeshift
|