tweek 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tweek/__init__.py +16 -0
- tweek/cli.py +3390 -0
- tweek/cli_helpers.py +193 -0
- tweek/config/__init__.py +13 -0
- tweek/config/allowed_dirs.yaml +23 -0
- tweek/config/manager.py +1064 -0
- tweek/config/patterns.yaml +751 -0
- tweek/config/tiers.yaml +129 -0
- tweek/diagnostics.py +589 -0
- tweek/hooks/__init__.py +1 -0
- tweek/hooks/pre_tool_use.py +861 -0
- tweek/integrations/__init__.py +3 -0
- tweek/integrations/moltbot.py +243 -0
- tweek/licensing.py +398 -0
- tweek/logging/__init__.py +9 -0
- tweek/logging/bundle.py +350 -0
- tweek/logging/json_logger.py +150 -0
- tweek/logging/security_log.py +745 -0
- tweek/mcp/__init__.py +24 -0
- tweek/mcp/approval.py +456 -0
- tweek/mcp/approval_cli.py +356 -0
- tweek/mcp/clients/__init__.py +37 -0
- tweek/mcp/clients/chatgpt.py +112 -0
- tweek/mcp/clients/claude_desktop.py +203 -0
- tweek/mcp/clients/gemini.py +178 -0
- tweek/mcp/proxy.py +667 -0
- tweek/mcp/screening.py +175 -0
- tweek/mcp/server.py +317 -0
- tweek/platform/__init__.py +131 -0
- tweek/plugins/__init__.py +835 -0
- tweek/plugins/base.py +1080 -0
- tweek/plugins/compliance/__init__.py +30 -0
- tweek/plugins/compliance/gdpr.py +333 -0
- tweek/plugins/compliance/gov.py +324 -0
- tweek/plugins/compliance/hipaa.py +285 -0
- tweek/plugins/compliance/legal.py +322 -0
- tweek/plugins/compliance/pci.py +361 -0
- tweek/plugins/compliance/soc2.py +275 -0
- tweek/plugins/detectors/__init__.py +30 -0
- tweek/plugins/detectors/continue_dev.py +206 -0
- tweek/plugins/detectors/copilot.py +254 -0
- tweek/plugins/detectors/cursor.py +192 -0
- tweek/plugins/detectors/moltbot.py +205 -0
- tweek/plugins/detectors/windsurf.py +214 -0
- tweek/plugins/git_discovery.py +395 -0
- tweek/plugins/git_installer.py +491 -0
- tweek/plugins/git_lockfile.py +338 -0
- tweek/plugins/git_registry.py +503 -0
- tweek/plugins/git_security.py +482 -0
- tweek/plugins/providers/__init__.py +30 -0
- tweek/plugins/providers/anthropic.py +181 -0
- tweek/plugins/providers/azure_openai.py +289 -0
- tweek/plugins/providers/bedrock.py +248 -0
- tweek/plugins/providers/google.py +197 -0
- tweek/plugins/providers/openai.py +230 -0
- tweek/plugins/scope.py +130 -0
- tweek/plugins/screening/__init__.py +26 -0
- tweek/plugins/screening/llm_reviewer.py +149 -0
- tweek/plugins/screening/pattern_matcher.py +273 -0
- tweek/plugins/screening/rate_limiter.py +174 -0
- tweek/plugins/screening/session_analyzer.py +159 -0
- tweek/proxy/__init__.py +302 -0
- tweek/proxy/addon.py +223 -0
- tweek/proxy/interceptor.py +313 -0
- tweek/proxy/server.py +315 -0
- tweek/sandbox/__init__.py +71 -0
- tweek/sandbox/executor.py +382 -0
- tweek/sandbox/linux.py +278 -0
- tweek/sandbox/profile_generator.py +323 -0
- tweek/screening/__init__.py +13 -0
- tweek/screening/context.py +81 -0
- tweek/security/__init__.py +22 -0
- tweek/security/llm_reviewer.py +348 -0
- tweek/security/rate_limiter.py +682 -0
- tweek/security/secret_scanner.py +506 -0
- tweek/security/session_analyzer.py +600 -0
- tweek/vault/__init__.py +40 -0
- tweek/vault/cross_platform.py +251 -0
- tweek/vault/keychain.py +288 -0
- tweek-0.1.0.dist-info/METADATA +335 -0
- tweek-0.1.0.dist-info/RECORD +85 -0
- tweek-0.1.0.dist-info/WHEEL +5 -0
- tweek-0.1.0.dist-info/entry_points.txt +25 -0
- tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
- tweek-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Git Plugin Installer
|
|
4
|
+
|
|
5
|
+
Handles git-based plugin installation operations:
|
|
6
|
+
- Clone plugin repos from registry
|
|
7
|
+
- Update to latest or specific version
|
|
8
|
+
- Remove installed plugins
|
|
9
|
+
- Verify installation integrity
|
|
10
|
+
|
|
11
|
+
All git operations use subprocess.run with:
|
|
12
|
+
- capture_output=True (no terminal access)
|
|
13
|
+
- timeout=30 (prevent hangs)
|
|
14
|
+
- No shell=True (no injection risk)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from tweek.plugins.git_registry import (
|
|
26
|
+
PLUGINS_DIR,
|
|
27
|
+
TWEEK_HOME,
|
|
28
|
+
PluginRegistryClient,
|
|
29
|
+
RegistryEntry,
|
|
30
|
+
RegistryError,
|
|
31
|
+
)
|
|
32
|
+
from tweek.plugins.git_security import (
|
|
33
|
+
PluginSecurityError,
|
|
34
|
+
generate_checksums,
|
|
35
|
+
validate_manifest,
|
|
36
|
+
validate_plugin_full,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Git operation timeout in seconds
|
|
42
|
+
GIT_TIMEOUT = 30
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InstallError(Exception):
|
|
46
|
+
"""Raised when plugin installation fails."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitPluginInstaller:
|
|
51
|
+
"""
|
|
52
|
+
Installs, updates, and removes git-based plugins.
|
|
53
|
+
|
|
54
|
+
Each plugin is cloned into ~/.tweek/plugins/{name}/
|
|
55
|
+
Updates are git fetch + checkout to specific tag.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
registry_client: Optional[PluginRegistryClient] = None,
|
|
61
|
+
plugins_dir: Optional[Path] = None,
|
|
62
|
+
):
|
|
63
|
+
self._registry = registry_client or PluginRegistryClient()
|
|
64
|
+
self._plugins_dir = plugins_dir or PLUGINS_DIR
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def plugins_dir(self) -> Path:
|
|
68
|
+
return self._plugins_dir
|
|
69
|
+
|
|
70
|
+
def install(
|
|
71
|
+
self,
|
|
72
|
+
name: str,
|
|
73
|
+
version: Optional[str] = None,
|
|
74
|
+
verify: bool = True,
|
|
75
|
+
) -> Tuple[bool, str]:
|
|
76
|
+
"""
|
|
77
|
+
Install a plugin from the registry.
|
|
78
|
+
|
|
79
|
+
Steps:
|
|
80
|
+
1. Look up plugin in registry
|
|
81
|
+
2. Verify it's approved (verified=True)
|
|
82
|
+
3. Git clone --depth 1
|
|
83
|
+
4. Checkout specific version tag
|
|
84
|
+
5. Run security verification pipeline
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
name: Plugin name (e.g., "tweek-plugin-cursor-detector")
|
|
88
|
+
version: Specific version to install (default: latest)
|
|
89
|
+
verify: Run security verification after install
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
(success, message)
|
|
93
|
+
"""
|
|
94
|
+
# Look up in registry
|
|
95
|
+
entry = self._registry.get_plugin(name)
|
|
96
|
+
if entry is None:
|
|
97
|
+
return False, f"Plugin '{name}' not found in registry or not verified"
|
|
98
|
+
|
|
99
|
+
if entry.deprecated:
|
|
100
|
+
logger.warning(f"Plugin '{name}' is deprecated")
|
|
101
|
+
|
|
102
|
+
# Determine version
|
|
103
|
+
target_version = version or entry.latest_version
|
|
104
|
+
if not target_version:
|
|
105
|
+
return False, f"No version available for '{name}'"
|
|
106
|
+
|
|
107
|
+
# Check version exists in registry
|
|
108
|
+
version_info = entry.get_version_info(target_version)
|
|
109
|
+
if version_info is None:
|
|
110
|
+
available = ", ".join(entry.versions.keys())
|
|
111
|
+
return False, (
|
|
112
|
+
f"Version '{target_version}' not found for '{name}'. "
|
|
113
|
+
f"Available versions: {available}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check if already installed
|
|
117
|
+
plugin_dir = self._plugins_dir / name
|
|
118
|
+
if plugin_dir.exists():
|
|
119
|
+
return False, (
|
|
120
|
+
f"Plugin '{name}' is already installed at {plugin_dir}. "
|
|
121
|
+
f"Use 'tweek plugins update {name}' to update."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Ensure plugins directory exists
|
|
125
|
+
self._plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
# Git clone
|
|
128
|
+
repo_url = entry.repo_url
|
|
129
|
+
git_ref = entry.get_git_ref(target_version)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self._git_clone(repo_url, plugin_dir, git_ref)
|
|
133
|
+
except InstallError as e:
|
|
134
|
+
# Clean up on failure
|
|
135
|
+
if plugin_dir.exists():
|
|
136
|
+
shutil.rmtree(plugin_dir, ignore_errors=True)
|
|
137
|
+
return False, f"Git clone failed: {e}"
|
|
138
|
+
|
|
139
|
+
# Verify installation
|
|
140
|
+
if verify:
|
|
141
|
+
try:
|
|
142
|
+
checksums = entry.get_checksums(target_version)
|
|
143
|
+
self._verify_installed_plugin(plugin_dir, checksums)
|
|
144
|
+
except (PluginSecurityError, InstallError) as e:
|
|
145
|
+
# Security check failed - remove the plugin
|
|
146
|
+
shutil.rmtree(plugin_dir, ignore_errors=True)
|
|
147
|
+
return False, f"Security verification failed: {e}"
|
|
148
|
+
|
|
149
|
+
logger.info(f"Successfully installed {name} v{target_version}")
|
|
150
|
+
return True, f"Installed {name} v{target_version}"
|
|
151
|
+
|
|
152
|
+
def update(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
version: Optional[str] = None,
|
|
156
|
+
verify: bool = True,
|
|
157
|
+
) -> Tuple[bool, str]:
|
|
158
|
+
"""
|
|
159
|
+
Update an installed plugin.
|
|
160
|
+
|
|
161
|
+
Steps:
|
|
162
|
+
1. Verify plugin is installed
|
|
163
|
+
2. Git fetch
|
|
164
|
+
3. Checkout new version tag
|
|
165
|
+
4. Re-run security verification
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
name: Plugin name
|
|
169
|
+
version: Specific version to update to (default: latest)
|
|
170
|
+
verify: Run security verification after update
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
(success, message)
|
|
174
|
+
"""
|
|
175
|
+
plugin_dir = self._plugins_dir / name
|
|
176
|
+
if not plugin_dir.exists():
|
|
177
|
+
return False, f"Plugin '{name}' is not installed"
|
|
178
|
+
|
|
179
|
+
# Look up in registry
|
|
180
|
+
entry = self._registry.get_plugin(name)
|
|
181
|
+
if entry is None:
|
|
182
|
+
return False, f"Plugin '{name}' not found in registry"
|
|
183
|
+
|
|
184
|
+
# Determine target version
|
|
185
|
+
target_version = version or entry.latest_version
|
|
186
|
+
if not target_version:
|
|
187
|
+
return False, f"No version available for '{name}'"
|
|
188
|
+
|
|
189
|
+
# Check version exists
|
|
190
|
+
version_info = entry.get_version_info(target_version)
|
|
191
|
+
if version_info is None:
|
|
192
|
+
return False, f"Version '{target_version}' not found for '{name}'"
|
|
193
|
+
|
|
194
|
+
git_ref = entry.get_git_ref(target_version)
|
|
195
|
+
|
|
196
|
+
# Read current version
|
|
197
|
+
current_version = self._get_installed_version(plugin_dir)
|
|
198
|
+
if current_version == target_version:
|
|
199
|
+
return True, f"Plugin '{name}' is already at version {target_version}"
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
self._git_fetch_checkout(plugin_dir, git_ref)
|
|
203
|
+
except InstallError as e:
|
|
204
|
+
return False, f"Git update failed: {e}"
|
|
205
|
+
|
|
206
|
+
# Verify updated plugin
|
|
207
|
+
if verify:
|
|
208
|
+
try:
|
|
209
|
+
checksums = entry.get_checksums(target_version)
|
|
210
|
+
self._verify_installed_plugin(plugin_dir, checksums)
|
|
211
|
+
except (PluginSecurityError, InstallError) as e:
|
|
212
|
+
# Revert to previous version if possible
|
|
213
|
+
if current_version:
|
|
214
|
+
old_ref = entry.get_git_ref(current_version)
|
|
215
|
+
try:
|
|
216
|
+
self._git_fetch_checkout(plugin_dir, old_ref)
|
|
217
|
+
logger.warning(f"Reverted {name} to v{current_version} after verification failure")
|
|
218
|
+
except InstallError:
|
|
219
|
+
pass
|
|
220
|
+
return False, f"Security verification failed after update: {e}"
|
|
221
|
+
|
|
222
|
+
logger.info(f"Successfully updated {name} to v{target_version}")
|
|
223
|
+
return True, f"Updated {name} from v{current_version} to v{target_version}"
|
|
224
|
+
|
|
225
|
+
def remove(self, name: str) -> Tuple[bool, str]:
|
|
226
|
+
"""
|
|
227
|
+
Remove an installed plugin.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
name: Plugin name
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
(success, message)
|
|
234
|
+
"""
|
|
235
|
+
plugin_dir = self._plugins_dir / name
|
|
236
|
+
if not plugin_dir.exists():
|
|
237
|
+
return False, f"Plugin '{name}' is not installed"
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
shutil.rmtree(plugin_dir)
|
|
241
|
+
logger.info(f"Removed plugin '{name}'")
|
|
242
|
+
return True, f"Removed plugin '{name}'"
|
|
243
|
+
except OSError as e:
|
|
244
|
+
return False, f"Failed to remove plugin '{name}': {e}"
|
|
245
|
+
|
|
246
|
+
def check_updates(self) -> List[Dict[str, str]]:
|
|
247
|
+
"""
|
|
248
|
+
Check all installed plugins for available updates.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of dicts with keys: name, current_version, latest_version
|
|
252
|
+
"""
|
|
253
|
+
updates = []
|
|
254
|
+
|
|
255
|
+
if not self._plugins_dir.exists():
|
|
256
|
+
return updates
|
|
257
|
+
|
|
258
|
+
for plugin_dir in self._plugins_dir.iterdir():
|
|
259
|
+
if not plugin_dir.is_dir():
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
name = plugin_dir.name
|
|
263
|
+
current = self._get_installed_version(plugin_dir)
|
|
264
|
+
if not current:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
latest = self._registry.get_update_available(name, current)
|
|
268
|
+
if latest:
|
|
269
|
+
updates.append({
|
|
270
|
+
"name": name,
|
|
271
|
+
"current_version": current,
|
|
272
|
+
"latest_version": latest,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
return updates
|
|
276
|
+
|
|
277
|
+
def list_installed(self) -> List[Dict[str, str]]:
|
|
278
|
+
"""
|
|
279
|
+
List all installed git plugins.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of dicts with keys: name, version, category, path
|
|
283
|
+
"""
|
|
284
|
+
installed = []
|
|
285
|
+
|
|
286
|
+
if not self._plugins_dir.exists():
|
|
287
|
+
return installed
|
|
288
|
+
|
|
289
|
+
for plugin_dir in self._plugins_dir.iterdir():
|
|
290
|
+
if not plugin_dir.is_dir():
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
manifest_path = plugin_dir / "tweek_plugin.json"
|
|
294
|
+
if not manifest_path.exists():
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
with open(manifest_path) as f:
|
|
299
|
+
manifest = json.load(f)
|
|
300
|
+
|
|
301
|
+
installed.append({
|
|
302
|
+
"name": manifest.get("name", plugin_dir.name),
|
|
303
|
+
"version": manifest.get("version", "unknown"),
|
|
304
|
+
"category": manifest.get("category", "unknown"),
|
|
305
|
+
"path": str(plugin_dir),
|
|
306
|
+
})
|
|
307
|
+
except (json.JSONDecodeError, IOError):
|
|
308
|
+
installed.append({
|
|
309
|
+
"name": plugin_dir.name,
|
|
310
|
+
"version": "unknown",
|
|
311
|
+
"category": "unknown",
|
|
312
|
+
"path": str(plugin_dir),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
return installed
|
|
316
|
+
|
|
317
|
+
def verify_plugin(self, name: str) -> Tuple[bool, List[str]]:
|
|
318
|
+
"""
|
|
319
|
+
Verify the integrity of an installed plugin.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
name: Plugin name
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
(is_valid, list_of_issues)
|
|
326
|
+
"""
|
|
327
|
+
plugin_dir = self._plugins_dir / name
|
|
328
|
+
if not plugin_dir.exists():
|
|
329
|
+
return False, [f"Plugin '{name}' is not installed"]
|
|
330
|
+
|
|
331
|
+
manifest_path = plugin_dir / "tweek_plugin.json"
|
|
332
|
+
valid, manifest, issues = validate_manifest(manifest_path)
|
|
333
|
+
if not valid:
|
|
334
|
+
return False, issues
|
|
335
|
+
|
|
336
|
+
# Get checksums from registry
|
|
337
|
+
entry = self._registry.get_plugin(name)
|
|
338
|
+
checksums = {}
|
|
339
|
+
if entry:
|
|
340
|
+
version = manifest.get("version", "")
|
|
341
|
+
checksums = entry.get_checksums(version)
|
|
342
|
+
|
|
343
|
+
return validate_plugin_full(
|
|
344
|
+
plugin_dir,
|
|
345
|
+
manifest,
|
|
346
|
+
registry_checksums=checksums,
|
|
347
|
+
skip_signature=False,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def verify_all(self) -> Dict[str, Tuple[bool, List[str]]]:
|
|
351
|
+
"""
|
|
352
|
+
Verify integrity of all installed plugins.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Dict mapping plugin name to (is_valid, issues)
|
|
356
|
+
"""
|
|
357
|
+
results = {}
|
|
358
|
+
|
|
359
|
+
if not self._plugins_dir.exists():
|
|
360
|
+
return results
|
|
361
|
+
|
|
362
|
+
for plugin_dir in self._plugins_dir.iterdir():
|
|
363
|
+
if not plugin_dir.is_dir():
|
|
364
|
+
continue
|
|
365
|
+
name = plugin_dir.name
|
|
366
|
+
results[name] = self.verify_plugin(name)
|
|
367
|
+
|
|
368
|
+
return results
|
|
369
|
+
|
|
370
|
+
# =========================================================================
|
|
371
|
+
# INTERNAL HELPERS
|
|
372
|
+
# =========================================================================
|
|
373
|
+
|
|
374
|
+
def _git_clone(self, repo_url: str, target_dir: Path, git_ref: str) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Clone a git repository.
|
|
377
|
+
|
|
378
|
+
Uses --depth 1 for shallow clone and checks out specific ref.
|
|
379
|
+
"""
|
|
380
|
+
# Clone with depth 1
|
|
381
|
+
result = subprocess.run(
|
|
382
|
+
["git", "clone", "--depth", "1", "--branch", git_ref, repo_url, str(target_dir)],
|
|
383
|
+
capture_output=True,
|
|
384
|
+
text=True,
|
|
385
|
+
timeout=GIT_TIMEOUT,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if result.returncode != 0:
|
|
389
|
+
# Try cloning without --branch (ref might not be a branch/tag)
|
|
390
|
+
result2 = subprocess.run(
|
|
391
|
+
["git", "clone", "--depth", "1", repo_url, str(target_dir)],
|
|
392
|
+
capture_output=True,
|
|
393
|
+
text=True,
|
|
394
|
+
timeout=GIT_TIMEOUT,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if result2.returncode != 0:
|
|
398
|
+
raise InstallError(
|
|
399
|
+
f"git clone failed: {result2.stderr.strip() or result.stderr.strip()}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Fetch and checkout the specific ref
|
|
403
|
+
self._git_fetch_checkout(target_dir, git_ref)
|
|
404
|
+
|
|
405
|
+
def _git_fetch_checkout(self, plugin_dir: Path, git_ref: str) -> None:
|
|
406
|
+
"""
|
|
407
|
+
Fetch latest changes and checkout a specific ref.
|
|
408
|
+
"""
|
|
409
|
+
# Fetch all tags
|
|
410
|
+
result = subprocess.run(
|
|
411
|
+
["git", "fetch", "--tags", "--depth", "1"],
|
|
412
|
+
capture_output=True,
|
|
413
|
+
text=True,
|
|
414
|
+
timeout=GIT_TIMEOUT,
|
|
415
|
+
cwd=str(plugin_dir),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if result.returncode != 0:
|
|
419
|
+
raise InstallError(f"git fetch failed: {result.stderr.strip()}")
|
|
420
|
+
|
|
421
|
+
# Checkout the ref
|
|
422
|
+
result = subprocess.run(
|
|
423
|
+
["git", "checkout", git_ref],
|
|
424
|
+
capture_output=True,
|
|
425
|
+
text=True,
|
|
426
|
+
timeout=GIT_TIMEOUT,
|
|
427
|
+
cwd=str(plugin_dir),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if result.returncode != 0:
|
|
431
|
+
raise InstallError(f"git checkout {git_ref} failed: {result.stderr.strip()}")
|
|
432
|
+
|
|
433
|
+
def _get_installed_version(self, plugin_dir: Path) -> Optional[str]:
|
|
434
|
+
"""Get the version of an installed plugin from its manifest."""
|
|
435
|
+
manifest_path = plugin_dir / "tweek_plugin.json"
|
|
436
|
+
if not manifest_path.exists():
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
with open(manifest_path) as f:
|
|
441
|
+
manifest = json.load(f)
|
|
442
|
+
return manifest.get("version")
|
|
443
|
+
except (json.JSONDecodeError, IOError):
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
def _verify_installed_plugin(
|
|
447
|
+
self,
|
|
448
|
+
plugin_dir: Path,
|
|
449
|
+
registry_checksums: Optional[Dict[str, str]] = None,
|
|
450
|
+
) -> None:
|
|
451
|
+
"""
|
|
452
|
+
Run security verification on an installed plugin.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
PluginSecurityError: If verification fails
|
|
456
|
+
InstallError: If manifest is invalid
|
|
457
|
+
"""
|
|
458
|
+
manifest_path = plugin_dir / "tweek_plugin.json"
|
|
459
|
+
valid, manifest, issues = validate_manifest(manifest_path)
|
|
460
|
+
if not valid:
|
|
461
|
+
raise InstallError(
|
|
462
|
+
f"Invalid manifest: {'; '.join(issues)}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
is_safe, security_issues = validate_plugin_full(
|
|
466
|
+
plugin_dir,
|
|
467
|
+
manifest,
|
|
468
|
+
registry_checksums=registry_checksums,
|
|
469
|
+
skip_signature=False,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if not is_safe:
|
|
473
|
+
raise PluginSecurityError(
|
|
474
|
+
f"Plugin failed security validation: {'; '.join(security_issues)}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _get_git_commit(self, plugin_dir: Path) -> Optional[str]:
|
|
478
|
+
"""Get the current git commit SHA of an installed plugin."""
|
|
479
|
+
try:
|
|
480
|
+
result = subprocess.run(
|
|
481
|
+
["git", "rev-parse", "HEAD"],
|
|
482
|
+
capture_output=True,
|
|
483
|
+
text=True,
|
|
484
|
+
timeout=5,
|
|
485
|
+
cwd=str(plugin_dir),
|
|
486
|
+
)
|
|
487
|
+
if result.returncode == 0:
|
|
488
|
+
return result.stdout.strip()
|
|
489
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
490
|
+
pass
|
|
491
|
+
return None
|