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,835 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Plugin System
|
|
4
|
+
|
|
5
|
+
Modular plugin architecture supporting:
|
|
6
|
+
- Domain compliance modules (Gov, HIPAA, PCI, Legal)
|
|
7
|
+
- LLM provider plugins (Anthropic, OpenAI, Google, Bedrock)
|
|
8
|
+
- Tool detector plugins (moltbot, Cursor, Continue)
|
|
9
|
+
- Screening method plugins (rate limiting, pattern matching, LLM review)
|
|
10
|
+
|
|
11
|
+
Plugin Discovery:
|
|
12
|
+
- Built-in plugins are registered automatically
|
|
13
|
+
- External plugins discovered via Python entry_points
|
|
14
|
+
- Plugins can be enabled/disabled via configuration
|
|
15
|
+
|
|
16
|
+
License Tiers:
|
|
17
|
+
- FREE: Core pattern matching, basic screening
|
|
18
|
+
- PRO: LLM review, session analysis, rate limiting
|
|
19
|
+
- ENTERPRISE: Compliance modules (Gov, HIPAA, PCI, Legal)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Optional, Dict, List, Any, Type, Callable
|
|
25
|
+
from importlib.metadata import entry_points
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Thread lock for singleton pattern
|
|
32
|
+
_registry_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PluginSource(Enum):
|
|
36
|
+
"""How a plugin was installed/discovered."""
|
|
37
|
+
BUILTIN = "builtin" # Bundled with Tweek
|
|
38
|
+
GIT = "git" # Installed from git repository
|
|
39
|
+
ENTRY_POINT = "entry_point" # Discovered via Python entry_points
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PluginCategory(Enum):
|
|
43
|
+
"""Categories of plugins supported by Tweek."""
|
|
44
|
+
COMPLIANCE = "tweek.compliance" # Gov, HIPAA, PCI, Legal
|
|
45
|
+
LLM_PROVIDER = "tweek.llm_providers" # Anthropic, OpenAI, etc.
|
|
46
|
+
TOOL_DETECTOR = "tweek.tool_detectors" # moltbot, Cursor, etc.
|
|
47
|
+
SCREENING = "tweek.screening" # rate_limiter, pattern_matcher
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LicenseTier(Enum):
|
|
51
|
+
"""License tiers for plugin access."""
|
|
52
|
+
FREE = "free"
|
|
53
|
+
PRO = "pro"
|
|
54
|
+
ENTERPRISE = "enterprise"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PluginMetadata:
|
|
59
|
+
"""Metadata describing a plugin."""
|
|
60
|
+
name: str
|
|
61
|
+
version: str
|
|
62
|
+
category: PluginCategory
|
|
63
|
+
description: str
|
|
64
|
+
author: Optional[str] = None
|
|
65
|
+
requires_license: LicenseTier = LicenseTier.FREE
|
|
66
|
+
homepage: Optional[str] = None
|
|
67
|
+
tags: List[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
# Ensure tags is a list
|
|
71
|
+
if self.tags is None:
|
|
72
|
+
self.tags = []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class PluginInfo:
|
|
77
|
+
"""Runtime information about a loaded plugin."""
|
|
78
|
+
metadata: PluginMetadata
|
|
79
|
+
plugin_class: Type
|
|
80
|
+
instance: Optional[Any] = None
|
|
81
|
+
enabled: bool = True
|
|
82
|
+
load_error: Optional[str] = None
|
|
83
|
+
source: PluginSource = PluginSource.BUILTIN
|
|
84
|
+
install_path: Optional[str] = None
|
|
85
|
+
manifest: Optional[Dict[str, Any]] = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
return self.metadata.name
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def category(self) -> PluginCategory:
|
|
93
|
+
return self.metadata.category
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PluginRegistry:
|
|
97
|
+
"""
|
|
98
|
+
Central registry for all Tweek plugins.
|
|
99
|
+
|
|
100
|
+
Handles plugin discovery, registration, and lifecycle management.
|
|
101
|
+
Supports both built-in plugins and external plugins via entry_points.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self):
|
|
105
|
+
self._plugins: Dict[PluginCategory, Dict[str, PluginInfo]] = {
|
|
106
|
+
category: {} for category in PluginCategory
|
|
107
|
+
}
|
|
108
|
+
self._config: Dict[str, Dict[str, Any]] = {}
|
|
109
|
+
self._license_checker: Optional[Callable[[LicenseTier], bool]] = None
|
|
110
|
+
|
|
111
|
+
def set_license_checker(self, checker: Callable[[LicenseTier], bool]) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Set a function to check if a license tier is available.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
checker: Function that takes LicenseTier and returns True if available
|
|
117
|
+
"""
|
|
118
|
+
self._license_checker = checker
|
|
119
|
+
|
|
120
|
+
def _check_license(self, required: LicenseTier) -> bool:
|
|
121
|
+
"""Check if the required license tier is available."""
|
|
122
|
+
if self._license_checker is None:
|
|
123
|
+
# No checker set - allow FREE tier only
|
|
124
|
+
return required == LicenseTier.FREE
|
|
125
|
+
|
|
126
|
+
return self._license_checker(required)
|
|
127
|
+
|
|
128
|
+
def _log_plugin_event(self, operation: str, **kwargs):
|
|
129
|
+
"""Log plugin event to security logger (never raises)."""
|
|
130
|
+
try:
|
|
131
|
+
from tweek.logging.security_log import get_logger as get_sec_logger, SecurityEvent, EventType
|
|
132
|
+
metadata = {"operation": operation, **kwargs}
|
|
133
|
+
get_sec_logger().log(SecurityEvent(
|
|
134
|
+
event_type=EventType.PLUGIN_EVENT,
|
|
135
|
+
tool_name="plugin_registry",
|
|
136
|
+
decision="allow",
|
|
137
|
+
metadata=metadata,
|
|
138
|
+
source="plugins",
|
|
139
|
+
))
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def discover_plugins(self) -> int:
|
|
144
|
+
"""
|
|
145
|
+
Discover plugins via Python entry_points.
|
|
146
|
+
|
|
147
|
+
Compatible with Python 3.9, 3.10, 3.11, and 3.12.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Number of plugins discovered
|
|
151
|
+
"""
|
|
152
|
+
discovered = 0
|
|
153
|
+
|
|
154
|
+
for category in PluginCategory:
|
|
155
|
+
eps = self._get_entry_points(category.value)
|
|
156
|
+
|
|
157
|
+
for ep in eps:
|
|
158
|
+
try:
|
|
159
|
+
plugin_class = ep.load()
|
|
160
|
+
self.register(ep.name, plugin_class, category)
|
|
161
|
+
discovered += 1
|
|
162
|
+
logger.debug(f"Discovered plugin: {ep.name} ({category.value})")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"Failed to load plugin {ep.name}: {e}")
|
|
165
|
+
self._log_plugin_event(
|
|
166
|
+
"discover_failed", plugin=ep.name, category=category.value, error=str(e)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if discovered > 0:
|
|
170
|
+
self._log_plugin_event("discover_complete", discovered_count=discovered)
|
|
171
|
+
|
|
172
|
+
return discovered
|
|
173
|
+
|
|
174
|
+
def _get_entry_points(self, group: str) -> List:
|
|
175
|
+
"""
|
|
176
|
+
Get entry points for a group, compatible with Python 3.9+.
|
|
177
|
+
|
|
178
|
+
Python 3.9: entry_points() returns dict-like SelectableGroups
|
|
179
|
+
Python 3.10+: entry_points(group=...) returns EntryPoints directly
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
group: The entry point group name
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of entry points (may be empty)
|
|
186
|
+
"""
|
|
187
|
+
import sys
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
if sys.version_info >= (3, 10):
|
|
191
|
+
# Python 3.10+ - use keyword argument
|
|
192
|
+
return list(entry_points(group=group))
|
|
193
|
+
else:
|
|
194
|
+
# Python 3.9 - returns dict-like SelectableGroups
|
|
195
|
+
all_eps = entry_points()
|
|
196
|
+
|
|
197
|
+
# In Python 3.9, entry_points() returns SelectableGroups
|
|
198
|
+
# which is dict-like with group names as keys
|
|
199
|
+
if hasattr(all_eps, 'get'):
|
|
200
|
+
# Standard dict-like access
|
|
201
|
+
return list(all_eps.get(group, []))
|
|
202
|
+
elif hasattr(all_eps, 'select'):
|
|
203
|
+
# Some versions support select()
|
|
204
|
+
return list(all_eps.select(group=group))
|
|
205
|
+
else:
|
|
206
|
+
# Fallback: iterate and filter
|
|
207
|
+
return [ep for ep in all_eps if getattr(ep, 'group', None) == group]
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.warning(f"Failed to get entry points for {group}: {e}")
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
def register(
|
|
214
|
+
self,
|
|
215
|
+
name: str,
|
|
216
|
+
plugin_class: Type,
|
|
217
|
+
category: PluginCategory,
|
|
218
|
+
metadata: Optional[PluginMetadata] = None,
|
|
219
|
+
source: PluginSource = PluginSource.BUILTIN,
|
|
220
|
+
install_path: Optional[str] = None,
|
|
221
|
+
manifest: Optional[Dict[str, Any]] = None,
|
|
222
|
+
override: bool = False,
|
|
223
|
+
) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Register a plugin.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
name: Plugin name (must be unique within category)
|
|
229
|
+
plugin_class: Plugin class (not instance)
|
|
230
|
+
category: Plugin category
|
|
231
|
+
metadata: Optional metadata (extracted from class if not provided)
|
|
232
|
+
source: How the plugin was installed (builtin, git, entry_point)
|
|
233
|
+
install_path: Filesystem path for git-installed plugins
|
|
234
|
+
manifest: Parsed manifest dict for git-installed plugins
|
|
235
|
+
override: If True, replace existing registration (for git overriding builtin)
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True if registered successfully
|
|
239
|
+
"""
|
|
240
|
+
# Extract metadata from class if not provided
|
|
241
|
+
if metadata is None:
|
|
242
|
+
metadata = self._extract_metadata(name, plugin_class, category)
|
|
243
|
+
|
|
244
|
+
# Check if already registered
|
|
245
|
+
if name in self._plugins[category]:
|
|
246
|
+
if override:
|
|
247
|
+
logger.info(
|
|
248
|
+
f"Git plugin {name} overriding builtin in {category.value}"
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
logger.warning(f"Plugin {name} already registered in {category.value}")
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# Create plugin info
|
|
255
|
+
info = PluginInfo(
|
|
256
|
+
metadata=metadata,
|
|
257
|
+
plugin_class=plugin_class,
|
|
258
|
+
enabled=True,
|
|
259
|
+
source=source,
|
|
260
|
+
install_path=install_path,
|
|
261
|
+
manifest=manifest,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
self._plugins[category][name] = info
|
|
265
|
+
logger.debug(f"Registered plugin: {name} ({category.value}, source={source.value})")
|
|
266
|
+
self._log_plugin_event(
|
|
267
|
+
"register", plugin=name, category=category.value, source=source.value
|
|
268
|
+
)
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def _extract_metadata(
|
|
272
|
+
self,
|
|
273
|
+
name: str,
|
|
274
|
+
plugin_class: Type,
|
|
275
|
+
category: PluginCategory
|
|
276
|
+
) -> PluginMetadata:
|
|
277
|
+
"""Extract metadata from a plugin class."""
|
|
278
|
+
# Try to get metadata from class attributes
|
|
279
|
+
version = getattr(plugin_class, 'VERSION', '0.0.0')
|
|
280
|
+
description = getattr(plugin_class, 'DESCRIPTION', plugin_class.__doc__ or '')
|
|
281
|
+
author = getattr(plugin_class, 'AUTHOR', None)
|
|
282
|
+
requires_license = getattr(plugin_class, 'REQUIRES_LICENSE', LicenseTier.FREE)
|
|
283
|
+
tags = getattr(plugin_class, 'TAGS', [])
|
|
284
|
+
|
|
285
|
+
# Handle string license tier
|
|
286
|
+
if isinstance(requires_license, str):
|
|
287
|
+
try:
|
|
288
|
+
requires_license = LicenseTier(requires_license)
|
|
289
|
+
except ValueError:
|
|
290
|
+
requires_license = LicenseTier.FREE
|
|
291
|
+
|
|
292
|
+
return PluginMetadata(
|
|
293
|
+
name=name,
|
|
294
|
+
version=version,
|
|
295
|
+
category=category,
|
|
296
|
+
description=description.strip() if description else '',
|
|
297
|
+
author=author,
|
|
298
|
+
requires_license=requires_license,
|
|
299
|
+
tags=tags
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def unregister(self, name: str, category: PluginCategory) -> bool:
|
|
303
|
+
"""
|
|
304
|
+
Unregister a plugin.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
name: Plugin name
|
|
308
|
+
category: Plugin category
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
True if unregistered successfully
|
|
312
|
+
"""
|
|
313
|
+
if name in self._plugins[category]:
|
|
314
|
+
del self._plugins[category][name]
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
def get(
|
|
319
|
+
self,
|
|
320
|
+
name: str,
|
|
321
|
+
category: PluginCategory,
|
|
322
|
+
instantiate: bool = True
|
|
323
|
+
) -> Optional[Any]:
|
|
324
|
+
"""
|
|
325
|
+
Get a plugin instance.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
name: Plugin name
|
|
329
|
+
category: Plugin category
|
|
330
|
+
instantiate: If True, return instance; if False, return class
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Plugin instance/class if found and enabled, None otherwise
|
|
334
|
+
"""
|
|
335
|
+
info = self._plugins[category].get(name)
|
|
336
|
+
if info is None:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
if not info.enabled:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Check license
|
|
343
|
+
if not self._check_license(info.metadata.requires_license):
|
|
344
|
+
logger.debug(
|
|
345
|
+
f"Plugin {name} requires {info.metadata.requires_license.value} license"
|
|
346
|
+
)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
if not instantiate:
|
|
350
|
+
return info.plugin_class
|
|
351
|
+
|
|
352
|
+
# Lazy instantiation
|
|
353
|
+
if info.instance is None:
|
|
354
|
+
try:
|
|
355
|
+
config = self._config.get(name, {})
|
|
356
|
+
info.instance = info.plugin_class(config) if config else info.plugin_class()
|
|
357
|
+
except Exception as e:
|
|
358
|
+
info.load_error = str(e)
|
|
359
|
+
logger.error(f"Failed to instantiate plugin {name}: {e}")
|
|
360
|
+
self._log_plugin_event(
|
|
361
|
+
"instantiate_failed", plugin=name, category=category.value, error=str(e)
|
|
362
|
+
)
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
return info.instance
|
|
366
|
+
|
|
367
|
+
def get_info(self, name: str, category: PluginCategory) -> Optional[PluginInfo]:
|
|
368
|
+
"""Get plugin info without instantiating."""
|
|
369
|
+
return self._plugins[category].get(name)
|
|
370
|
+
|
|
371
|
+
def get_all(
|
|
372
|
+
self,
|
|
373
|
+
category: PluginCategory,
|
|
374
|
+
enabled_only: bool = True,
|
|
375
|
+
licensed_only: bool = True
|
|
376
|
+
) -> List[Any]:
|
|
377
|
+
"""
|
|
378
|
+
Get all plugins in a category.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
category: Plugin category
|
|
382
|
+
enabled_only: Only return enabled plugins
|
|
383
|
+
licensed_only: Only return plugins user has license for
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
List of plugin instances
|
|
387
|
+
"""
|
|
388
|
+
plugins = []
|
|
389
|
+
|
|
390
|
+
for name, info in self._plugins[category].items():
|
|
391
|
+
if enabled_only and not info.enabled:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
if licensed_only and not self._check_license(info.metadata.requires_license):
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
instance = self.get(name, category)
|
|
398
|
+
if instance is not None:
|
|
399
|
+
plugins.append(instance)
|
|
400
|
+
|
|
401
|
+
return plugins
|
|
402
|
+
|
|
403
|
+
def list_plugins(
|
|
404
|
+
self,
|
|
405
|
+
category: Optional[PluginCategory] = None
|
|
406
|
+
) -> List[PluginInfo]:
|
|
407
|
+
"""
|
|
408
|
+
List all registered plugins.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
category: Optional category filter
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
List of PluginInfo objects
|
|
415
|
+
"""
|
|
416
|
+
plugins = []
|
|
417
|
+
|
|
418
|
+
categories = [category] if category else list(PluginCategory)
|
|
419
|
+
|
|
420
|
+
for cat in categories:
|
|
421
|
+
plugins.extend(self._plugins[cat].values())
|
|
422
|
+
|
|
423
|
+
return plugins
|
|
424
|
+
|
|
425
|
+
def enable(self, name: str, category: PluginCategory) -> bool:
|
|
426
|
+
"""Enable a plugin."""
|
|
427
|
+
info = self._plugins[category].get(name)
|
|
428
|
+
if info:
|
|
429
|
+
info.enabled = True
|
|
430
|
+
return True
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
def disable(self, name: str, category: PluginCategory) -> bool:
|
|
434
|
+
"""Disable a plugin."""
|
|
435
|
+
info = self._plugins[category].get(name)
|
|
436
|
+
if info:
|
|
437
|
+
info.enabled = False
|
|
438
|
+
return True
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
def is_enabled(self, name: str, category: PluginCategory) -> bool:
|
|
442
|
+
"""Check if a plugin is enabled."""
|
|
443
|
+
info = self._plugins[category].get(name)
|
|
444
|
+
return info.enabled if info else False
|
|
445
|
+
|
|
446
|
+
def configure(self, name: str, config: Dict[str, Any], invalidate: bool = True) -> None:
|
|
447
|
+
"""
|
|
448
|
+
Set configuration for a plugin.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
name: Plugin name
|
|
452
|
+
config: Configuration dictionary
|
|
453
|
+
invalidate: If True, invalidate existing instance to force recreation
|
|
454
|
+
with new config (recommended for config changes)
|
|
455
|
+
"""
|
|
456
|
+
self._config[name] = config
|
|
457
|
+
|
|
458
|
+
# Handle existing instances
|
|
459
|
+
for category in PluginCategory:
|
|
460
|
+
info = self._plugins[category].get(name)
|
|
461
|
+
if info and info.instance:
|
|
462
|
+
if invalidate:
|
|
463
|
+
# Invalidate instance so it gets recreated with new config
|
|
464
|
+
# on next access via get()
|
|
465
|
+
info.instance = None
|
|
466
|
+
info.load_error = None
|
|
467
|
+
elif hasattr(info.instance, 'configure'):
|
|
468
|
+
# Try hot-reconfiguration (for backward compatibility)
|
|
469
|
+
info.instance.configure(config)
|
|
470
|
+
|
|
471
|
+
def get_config(self, name: str) -> Dict[str, Any]:
|
|
472
|
+
"""Get configuration for a plugin."""
|
|
473
|
+
return self._config.get(name, {})
|
|
474
|
+
|
|
475
|
+
def load_config(self, config: Dict[str, Any]) -> None:
|
|
476
|
+
"""
|
|
477
|
+
Load plugin configuration from a dictionary.
|
|
478
|
+
|
|
479
|
+
Expected format:
|
|
480
|
+
{
|
|
481
|
+
"plugins": {
|
|
482
|
+
"compliance": {
|
|
483
|
+
"gov": {"enabled": true, "actions": {...}},
|
|
484
|
+
"hipaa": {"enabled": false}
|
|
485
|
+
},
|
|
486
|
+
"screening": {
|
|
487
|
+
"rate_limiter": {"enabled": true, "burst_threshold": 15}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
"""
|
|
492
|
+
plugins_config = config.get("plugins", {})
|
|
493
|
+
|
|
494
|
+
# Map category names to enums
|
|
495
|
+
category_map = {
|
|
496
|
+
"compliance": PluginCategory.COMPLIANCE,
|
|
497
|
+
"providers": PluginCategory.LLM_PROVIDER,
|
|
498
|
+
"detectors": PluginCategory.TOOL_DETECTOR,
|
|
499
|
+
"screening": PluginCategory.SCREENING,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for cat_name, cat_config in plugins_config.items():
|
|
503
|
+
category = category_map.get(cat_name)
|
|
504
|
+
if category is None:
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
# Handle "modules" sub-key or direct plugin configs
|
|
508
|
+
modules = cat_config.get("modules", cat_config)
|
|
509
|
+
if not isinstance(modules, dict):
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
for plugin_name, plugin_config in modules.items():
|
|
513
|
+
if not isinstance(plugin_config, dict):
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
# Enable/disable
|
|
517
|
+
if "enabled" in plugin_config:
|
|
518
|
+
if plugin_config["enabled"]:
|
|
519
|
+
self.enable(plugin_name, category)
|
|
520
|
+
else:
|
|
521
|
+
self.disable(plugin_name, category)
|
|
522
|
+
|
|
523
|
+
# Store config
|
|
524
|
+
self.configure(plugin_name, plugin_config)
|
|
525
|
+
|
|
526
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
527
|
+
"""Get statistics about registered plugins."""
|
|
528
|
+
stats = {
|
|
529
|
+
"total": 0,
|
|
530
|
+
"enabled": 0,
|
|
531
|
+
"by_category": {},
|
|
532
|
+
"by_license": {tier.value: 0 for tier in LicenseTier}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
for category in PluginCategory:
|
|
536
|
+
cat_stats = {"total": 0, "enabled": 0}
|
|
537
|
+
|
|
538
|
+
for info in self._plugins[category].values():
|
|
539
|
+
stats["total"] += 1
|
|
540
|
+
cat_stats["total"] += 1
|
|
541
|
+
stats["by_license"][info.metadata.requires_license.value] += 1
|
|
542
|
+
|
|
543
|
+
if info.enabled:
|
|
544
|
+
stats["enabled"] += 1
|
|
545
|
+
cat_stats["enabled"] += 1
|
|
546
|
+
|
|
547
|
+
stats["by_category"][category.value] = cat_stats
|
|
548
|
+
|
|
549
|
+
return stats
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# Global registry instance
|
|
553
|
+
_registry: Optional[PluginRegistry] = None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def get_registry() -> PluginRegistry:
|
|
557
|
+
"""
|
|
558
|
+
Get the global plugin registry.
|
|
559
|
+
|
|
560
|
+
Thread-safe singleton pattern using double-checked locking.
|
|
561
|
+
"""
|
|
562
|
+
global _registry
|
|
563
|
+
if _registry is None:
|
|
564
|
+
with _registry_lock:
|
|
565
|
+
# Double-check after acquiring lock
|
|
566
|
+
if _registry is None:
|
|
567
|
+
_registry = PluginRegistry()
|
|
568
|
+
return _registry
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def init_plugins(config: Optional[Dict[str, Any]] = None) -> PluginRegistry:
|
|
572
|
+
"""
|
|
573
|
+
Initialize the plugin system.
|
|
574
|
+
|
|
575
|
+
Loads plugins in order:
|
|
576
|
+
1. Built-in plugins (bundled with Tweek)
|
|
577
|
+
2. Git-installed plugins (~/.tweek/plugins/)
|
|
578
|
+
3. Entry point plugins (from installed packages)
|
|
579
|
+
|
|
580
|
+
Git plugins with the same name as a builtin will override the builtin.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
config: Optional configuration dictionary
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
The initialized plugin registry
|
|
587
|
+
"""
|
|
588
|
+
registry = get_registry()
|
|
589
|
+
|
|
590
|
+
# Log startup
|
|
591
|
+
try:
|
|
592
|
+
from tweek.logging.security_log import get_logger as get_sec_logger, SecurityEvent, EventType
|
|
593
|
+
get_sec_logger().log(SecurityEvent(
|
|
594
|
+
event_type=EventType.STARTUP,
|
|
595
|
+
tool_name="plugin_system",
|
|
596
|
+
decision="allow",
|
|
597
|
+
metadata={"operation": "init_plugins"},
|
|
598
|
+
source="plugins",
|
|
599
|
+
))
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
# Register built-in plugins
|
|
604
|
+
_register_builtin_plugins(registry)
|
|
605
|
+
|
|
606
|
+
# Discover git-installed plugins
|
|
607
|
+
_discover_git_plugins(registry)
|
|
608
|
+
|
|
609
|
+
# Discover external plugins via entry_points
|
|
610
|
+
registry.discover_plugins()
|
|
611
|
+
|
|
612
|
+
# Load configuration
|
|
613
|
+
if config:
|
|
614
|
+
registry.load_config(config)
|
|
615
|
+
|
|
616
|
+
# Set up license checker
|
|
617
|
+
try:
|
|
618
|
+
from tweek.licensing import get_license, Tier
|
|
619
|
+
|
|
620
|
+
def check_license(required: LicenseTier) -> bool:
|
|
621
|
+
"""
|
|
622
|
+
Check if the user's license tier meets the required tier.
|
|
623
|
+
|
|
624
|
+
Tier hierarchy: FREE < PRO < ENTERPRISE
|
|
625
|
+
Higher tiers include all lower tier features.
|
|
626
|
+
"""
|
|
627
|
+
lic = get_license()
|
|
628
|
+
tier_order = [Tier.FREE, Tier.PRO, Tier.ENTERPRISE]
|
|
629
|
+
|
|
630
|
+
# Map plugin LicenseTier to licensing Tier
|
|
631
|
+
tier_map = {
|
|
632
|
+
LicenseTier.FREE: Tier.FREE,
|
|
633
|
+
LicenseTier.PRO: Tier.PRO,
|
|
634
|
+
LicenseTier.ENTERPRISE: Tier.ENTERPRISE,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
required_tier = tier_map.get(required, Tier.FREE)
|
|
638
|
+
user_tier = lic.tier
|
|
639
|
+
|
|
640
|
+
# User has access if their tier is >= required tier
|
|
641
|
+
return tier_order.index(user_tier) >= tier_order.index(required_tier)
|
|
642
|
+
|
|
643
|
+
registry.set_license_checker(check_license)
|
|
644
|
+
except ImportError:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
return registry
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _register_builtin_plugins(registry: PluginRegistry) -> None:
|
|
651
|
+
"""Register all built-in plugins."""
|
|
652
|
+
# Compliance plugins
|
|
653
|
+
try:
|
|
654
|
+
from tweek.plugins.compliance import (
|
|
655
|
+
GovCompliancePlugin,
|
|
656
|
+
HIPAACompliancePlugin,
|
|
657
|
+
PCICompliancePlugin,
|
|
658
|
+
LegalCompliancePlugin,
|
|
659
|
+
SOC2CompliancePlugin,
|
|
660
|
+
GDPRCompliancePlugin,
|
|
661
|
+
)
|
|
662
|
+
registry.register("gov", GovCompliancePlugin, PluginCategory.COMPLIANCE)
|
|
663
|
+
registry.register("hipaa", HIPAACompliancePlugin, PluginCategory.COMPLIANCE)
|
|
664
|
+
registry.register("pci", PCICompliancePlugin, PluginCategory.COMPLIANCE)
|
|
665
|
+
registry.register("legal", LegalCompliancePlugin, PluginCategory.COMPLIANCE)
|
|
666
|
+
registry.register("soc2", SOC2CompliancePlugin, PluginCategory.COMPLIANCE)
|
|
667
|
+
registry.register("gdpr", GDPRCompliancePlugin, PluginCategory.COMPLIANCE)
|
|
668
|
+
except ImportError as e:
|
|
669
|
+
logger.debug(f"Compliance plugins not available: {e}")
|
|
670
|
+
|
|
671
|
+
# Provider plugins
|
|
672
|
+
try:
|
|
673
|
+
from tweek.plugins.providers import (
|
|
674
|
+
AnthropicProvider,
|
|
675
|
+
OpenAIProvider,
|
|
676
|
+
AzureOpenAIProvider,
|
|
677
|
+
GoogleProvider,
|
|
678
|
+
BedrockProvider,
|
|
679
|
+
)
|
|
680
|
+
registry.register("anthropic", AnthropicProvider, PluginCategory.LLM_PROVIDER)
|
|
681
|
+
registry.register("openai", OpenAIProvider, PluginCategory.LLM_PROVIDER)
|
|
682
|
+
registry.register("azure_openai", AzureOpenAIProvider, PluginCategory.LLM_PROVIDER)
|
|
683
|
+
registry.register("google", GoogleProvider, PluginCategory.LLM_PROVIDER)
|
|
684
|
+
registry.register("bedrock", BedrockProvider, PluginCategory.LLM_PROVIDER)
|
|
685
|
+
except ImportError as e:
|
|
686
|
+
logger.debug(f"Provider plugins not available: {e}")
|
|
687
|
+
|
|
688
|
+
# Detector plugins
|
|
689
|
+
try:
|
|
690
|
+
from tweek.plugins.detectors import (
|
|
691
|
+
MoltbotDetector,
|
|
692
|
+
CursorDetector,
|
|
693
|
+
ContinueDetector,
|
|
694
|
+
CopilotDetector,
|
|
695
|
+
WindsurfDetector,
|
|
696
|
+
)
|
|
697
|
+
registry.register("moltbot", MoltbotDetector, PluginCategory.TOOL_DETECTOR)
|
|
698
|
+
registry.register("cursor", CursorDetector, PluginCategory.TOOL_DETECTOR)
|
|
699
|
+
registry.register("continue", ContinueDetector, PluginCategory.TOOL_DETECTOR)
|
|
700
|
+
registry.register("copilot", CopilotDetector, PluginCategory.TOOL_DETECTOR)
|
|
701
|
+
registry.register("windsurf", WindsurfDetector, PluginCategory.TOOL_DETECTOR)
|
|
702
|
+
except ImportError as e:
|
|
703
|
+
logger.debug(f"Detector plugins not available: {e}")
|
|
704
|
+
|
|
705
|
+
# Screening plugins
|
|
706
|
+
try:
|
|
707
|
+
from tweek.plugins.screening import (
|
|
708
|
+
RateLimiterPlugin,
|
|
709
|
+
PatternMatcherPlugin,
|
|
710
|
+
LLMReviewerPlugin,
|
|
711
|
+
SessionAnalyzerPlugin,
|
|
712
|
+
)
|
|
713
|
+
registry.register("rate_limiter", RateLimiterPlugin, PluginCategory.SCREENING)
|
|
714
|
+
registry.register("pattern_matcher", PatternMatcherPlugin, PluginCategory.SCREENING)
|
|
715
|
+
registry.register("llm_reviewer", LLMReviewerPlugin, PluginCategory.SCREENING)
|
|
716
|
+
registry.register("session_analyzer", SessionAnalyzerPlugin, PluginCategory.SCREENING)
|
|
717
|
+
except ImportError as e:
|
|
718
|
+
logger.debug(f"Screening plugins not available: {e}")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _discover_git_plugins(registry: PluginRegistry) -> int:
|
|
722
|
+
"""
|
|
723
|
+
Discover and register git-installed plugins from ~/.tweek/plugins/.
|
|
724
|
+
|
|
725
|
+
Git plugins override builtins with the same name, allowing users
|
|
726
|
+
to use newer versions of bundled plugins via git.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
Number of git plugins registered
|
|
730
|
+
"""
|
|
731
|
+
try:
|
|
732
|
+
from tweek.plugins.git_discovery import discover_git_plugins
|
|
733
|
+
from tweek.plugins.git_registry import PluginRegistryClient
|
|
734
|
+
except ImportError:
|
|
735
|
+
logger.debug("Git plugin discovery modules not available")
|
|
736
|
+
return 0
|
|
737
|
+
|
|
738
|
+
# Map manifest categories to PluginCategory enum
|
|
739
|
+
category_map = {
|
|
740
|
+
"compliance": PluginCategory.COMPLIANCE,
|
|
741
|
+
"providers": PluginCategory.LLM_PROVIDER,
|
|
742
|
+
"detectors": PluginCategory.TOOL_DETECTOR,
|
|
743
|
+
"screening": PluginCategory.SCREENING,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
registered = 0
|
|
747
|
+
try:
|
|
748
|
+
registry_client = PluginRegistryClient()
|
|
749
|
+
plugins = discover_git_plugins(registry_client=registry_client)
|
|
750
|
+
|
|
751
|
+
for plugin in plugins:
|
|
752
|
+
category = category_map.get(plugin.category)
|
|
753
|
+
if category is None:
|
|
754
|
+
logger.warning(
|
|
755
|
+
f"Git plugin {plugin.name} has unknown category: {plugin.category}"
|
|
756
|
+
)
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
# Derive a short name from the full plugin name
|
|
760
|
+
# e.g., "tweek-plugin-cursor-detector" -> "cursor"
|
|
761
|
+
short_name = _derive_short_name(plugin.name, plugin.category)
|
|
762
|
+
|
|
763
|
+
# Git plugins override builtins
|
|
764
|
+
already_registered = short_name in registry._plugins.get(category, {})
|
|
765
|
+
|
|
766
|
+
success = registry.register(
|
|
767
|
+
name=short_name,
|
|
768
|
+
plugin_class=plugin.plugin_class,
|
|
769
|
+
category=category,
|
|
770
|
+
source=PluginSource.GIT,
|
|
771
|
+
install_path=str(plugin.plugin_dir),
|
|
772
|
+
manifest=plugin.manifest,
|
|
773
|
+
override=already_registered,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
if success:
|
|
777
|
+
registered += 1
|
|
778
|
+
|
|
779
|
+
except Exception as e:
|
|
780
|
+
logger.warning(f"Git plugin discovery failed: {e}")
|
|
781
|
+
try:
|
|
782
|
+
from tweek.logging.security_log import get_logger as get_sec_logger, SecurityEvent, EventType
|
|
783
|
+
get_sec_logger().log(SecurityEvent(
|
|
784
|
+
event_type=EventType.PLUGIN_EVENT,
|
|
785
|
+
tool_name="plugin_registry",
|
|
786
|
+
decision="error",
|
|
787
|
+
decision_reason=f"Git plugin discovery failed: {e}",
|
|
788
|
+
source="plugins",
|
|
789
|
+
))
|
|
790
|
+
except Exception:
|
|
791
|
+
pass
|
|
792
|
+
|
|
793
|
+
if registered > 0:
|
|
794
|
+
logger.info(f"Registered {registered} git plugin(s)")
|
|
795
|
+
|
|
796
|
+
return registered
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _derive_short_name(full_name: str, category: str) -> str:
|
|
800
|
+
"""
|
|
801
|
+
Derive a short plugin name from the full registry name.
|
|
802
|
+
|
|
803
|
+
Examples:
|
|
804
|
+
"tweek-plugin-cursor-detector" -> "cursor"
|
|
805
|
+
"tweek-plugin-hipaa" -> "hipaa"
|
|
806
|
+
"tweek-plugin-openai-provider" -> "openai"
|
|
807
|
+
"""
|
|
808
|
+
# Remove common prefixes
|
|
809
|
+
name = full_name
|
|
810
|
+
for prefix in ("tweek-plugin-", "tweek-"):
|
|
811
|
+
if name.startswith(prefix):
|
|
812
|
+
name = name[len(prefix):]
|
|
813
|
+
break
|
|
814
|
+
|
|
815
|
+
# Remove common suffixes
|
|
816
|
+
for suffix in ("-detector", "-provider", "-plugin", "-compliance", "-screening"):
|
|
817
|
+
if name.endswith(suffix):
|
|
818
|
+
name = name[:-len(suffix)]
|
|
819
|
+
break
|
|
820
|
+
|
|
821
|
+
# Replace hyphens with underscores
|
|
822
|
+
return name.replace("-", "_")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# Public API
|
|
826
|
+
__all__ = [
|
|
827
|
+
"PluginCategory",
|
|
828
|
+
"PluginSource",
|
|
829
|
+
"PluginMetadata",
|
|
830
|
+
"PluginInfo",
|
|
831
|
+
"PluginRegistry",
|
|
832
|
+
"LicenseTier",
|
|
833
|
+
"get_registry",
|
|
834
|
+
"init_plugins",
|
|
835
|
+
]
|